@aiwg/cli 2026.8.11 → 2026.8.13
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/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +6 -0
- package/dist/src/api/index.js +6 -0
- package/dist/src/cli/handlers/artifact-verify.js +171 -0
- package/dist/src/cli/handlers/index.js +3 -1
- package/dist/src/cli/handlers/setup-manifest.js +52 -3
- package/dist/src/cli/handlers/setup.js +15 -2
- package/dist/src/cli/handlers/use.js +71 -6
- package/dist/src/cli/scope-resolver.js +6 -1
- package/dist/src/cli/services/deployment-verification.js +65 -7
- package/dist/src/config/aiwg-config.js +4 -3
- package/dist/src/config/cli.js +3 -1
- package/dist/src/config/gitignore.js +67 -21
- package/dist/src/config/workspace.js +8 -1
- package/dist/src/extensions/commands/definitions.js +19 -0
- package/dist/src/extensions/project-quickref.js +9 -0
- package/dist/src/marketplace/artifact-attestation.js +195 -0
- package/dist/src/marketplace/exchange.js +437 -79
- package/dist/src/marketplace/provenance-types.js +1 -0
- package/dist/src/marketplace/provenance.js +7 -1
- package/dist/src/providers/hermes-home.js +20 -0
- package/dist/src/providers/provider-definitions.js +5 -4
- package/dist/src/providers/transformation-receipt-integration.js +448 -0
- package/dist/src/providers/transformation-receipt.js +215 -0
- package/dist/src/resources/web-release.d.ts +11 -0
- package/dist/src/resources/web-release.js +61 -6
- package/dist/src/security/artifact-attestation.js +117 -0
- package/dist/src/security/artifact-trust.js +557 -0
- package/dist/src/security/artifact-verifier.js +478 -0
- package/dist/src/skills/deployer.js +5 -1
- package/dist/src/tracker/capability-protocol.js +7 -2
- package/package.json +5 -1
- package/schemas/security/aiwg-artifact-attestation.v1.schema.json +106 -0
- package/schemas/security/aiwg-artifact-provenance.v1.schema.json +204 -0
- package/schemas/security/aiwg-artifact-trust-root.v1.schema.json +59 -0
- package/schemas/security/aiwg-artifact-trust-state.v1.schema.json +32 -0
- package/schemas/security/aiwg-artifact-verification-result.v1.schema.json +46 -0
- package/schemas/security/threat-assessment-input.v1.schema.json +57 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +104 -0
- package/tools/agents/deploy-agents.mjs +8 -2
- package/tools/agents/providers/base.mjs +29 -2
- package/tools/agents/providers/hermes.mjs +163 -19
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const PROVIDER_TRANSFORMATION_RECEIPT_SCHEMA = 'aiwg.provider-transformation-receipt.v1';
|
|
5
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
6
|
+
const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._:/@+-]*$/;
|
|
7
|
+
const FILENAME_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/;
|
|
8
|
+
function digest(bytes) {
|
|
9
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
function portableRelative(value, label) {
|
|
12
|
+
const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
13
|
+
if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) {
|
|
14
|
+
throw new Error(`${label} must be a portable relative path`);
|
|
15
|
+
}
|
|
16
|
+
if (normalized.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
17
|
+
throw new Error(`${label} must not escape its portable root`);
|
|
18
|
+
}
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
function identifier(value, label) {
|
|
22
|
+
if (!IDENTIFIER.test(value))
|
|
23
|
+
throw new Error(`${label} contains a non-portable identifier`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function filenameSegment(value, label) {
|
|
27
|
+
if (!FILENAME_SEGMENT.test(value) || value === '.' || value === '..') {
|
|
28
|
+
throw new Error(`${label} must be a portable filename segment`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function sha(value, label) {
|
|
33
|
+
if (!SHA256.test(value))
|
|
34
|
+
throw new Error(`${label} must be a lowercase SHA-256 digest`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
async function readRegularOutput(root, relative) {
|
|
38
|
+
const canonicalRoot = await realpath(root);
|
|
39
|
+
const absolute = path.resolve(canonicalRoot, relative);
|
|
40
|
+
const metadata = await lstat(absolute);
|
|
41
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) {
|
|
42
|
+
throw new Error(`receipt output '${relative}' must be a regular file and must not be a symbolic link`);
|
|
43
|
+
}
|
|
44
|
+
const canonicalOutput = await realpath(absolute);
|
|
45
|
+
const containment = path.relative(canonicalRoot, canonicalOutput);
|
|
46
|
+
if (containment === '..' || containment.startsWith(`..${path.sep}`) || path.isAbsolute(containment)) {
|
|
47
|
+
throw new Error(`receipt output '${relative}' resolves outside its configured root`);
|
|
48
|
+
}
|
|
49
|
+
return readFile(canonicalOutput);
|
|
50
|
+
}
|
|
51
|
+
export function providerTransformationReceiptPath(projectRoot, provider, scope) {
|
|
52
|
+
filenameSegment(provider, 'provider');
|
|
53
|
+
if (scope !== 'project' && scope !== 'user')
|
|
54
|
+
throw new Error('scope must be project or user');
|
|
55
|
+
return path.join(projectRoot, '.aiwg', 'receipts', 'providers', `${provider}.${scope}.json`);
|
|
56
|
+
}
|
|
57
|
+
export function validateProviderTransformationReceipt(value) {
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
59
|
+
throw new Error('receipt must be an object');
|
|
60
|
+
const receipt = value;
|
|
61
|
+
if (receipt.schemaVersion !== PROVIDER_TRANSFORMATION_RECEIPT_SCHEMA)
|
|
62
|
+
throw new Error('unsupported receipt schema');
|
|
63
|
+
if (!Number.isFinite(Date.parse(receipt.generatedAt)))
|
|
64
|
+
throw new Error('generatedAt must be an RFC 3339 date-time');
|
|
65
|
+
if (receipt.scope !== 'project' && receipt.scope !== 'user')
|
|
66
|
+
throw new Error('scope must be project or user');
|
|
67
|
+
filenameSegment(receipt.provider, 'provider');
|
|
68
|
+
identifier(receipt.source?.subject, 'source.subject');
|
|
69
|
+
sha(receipt.source?.sha256, 'source.sha256');
|
|
70
|
+
if (receipt.source?.verification !== 'verified') {
|
|
71
|
+
throw new Error('source.verification must be verified');
|
|
72
|
+
}
|
|
73
|
+
identifier(receipt.transformer?.id, 'transformer.id');
|
|
74
|
+
identifier(receipt.transformer?.version, 'transformer.version');
|
|
75
|
+
identifier(receipt.transformer?.providerAdapter, 'transformer.providerAdapter');
|
|
76
|
+
identifier(receipt.transformer?.providerAdapterVersion, 'transformer.providerAdapterVersion');
|
|
77
|
+
if (!Array.isArray(receipt.outputs) || receipt.outputs.length === 0)
|
|
78
|
+
throw new Error('outputs must not be empty');
|
|
79
|
+
const paths = new Set();
|
|
80
|
+
for (const [index, output] of receipt.outputs.entries()) {
|
|
81
|
+
output.path = portableRelative(output.path, `outputs[${index}].path`);
|
|
82
|
+
if (paths.has(output.path))
|
|
83
|
+
throw new Error(`duplicate output path '${output.path}'`);
|
|
84
|
+
paths.add(output.path);
|
|
85
|
+
sha(output.sha256, `outputs[${index}].sha256`);
|
|
86
|
+
if (!Number.isSafeInteger(output.bytes) || output.bytes < 0)
|
|
87
|
+
throw new Error(`outputs[${index}].bytes is invalid`);
|
|
88
|
+
}
|
|
89
|
+
receipt.outputs.sort((a, b) => a.path.localeCompare(b.path));
|
|
90
|
+
return receipt;
|
|
91
|
+
}
|
|
92
|
+
export async function createProviderTransformationReceipt(options) {
|
|
93
|
+
const outputs = [];
|
|
94
|
+
for (const raw of [...new Set(options.outputPaths)].sort()) {
|
|
95
|
+
const relative = portableRelative(raw, 'output path');
|
|
96
|
+
const bytes = await readRegularOutput(options.outputRoot ?? options.projectRoot, relative);
|
|
97
|
+
outputs.push({ path: relative, sha256: digest(bytes), bytes: bytes.byteLength });
|
|
98
|
+
}
|
|
99
|
+
return validateProviderTransformationReceipt({
|
|
100
|
+
schemaVersion: PROVIDER_TRANSFORMATION_RECEIPT_SCHEMA,
|
|
101
|
+
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
|
102
|
+
scope: options.scope,
|
|
103
|
+
provider: options.provider,
|
|
104
|
+
source: options.source,
|
|
105
|
+
transformer: options.transformer,
|
|
106
|
+
outputs,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export async function writeProviderTransformationReceipt(projectRoot, receipt) {
|
|
110
|
+
validateProviderTransformationReceipt(receipt);
|
|
111
|
+
const destination = providerTransformationReceiptPath(projectRoot, receipt.provider, receipt.scope);
|
|
112
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
113
|
+
try {
|
|
114
|
+
const existing = validateProviderTransformationReceipt(JSON.parse(await readFile(destination, 'utf8')));
|
|
115
|
+
const semantic = (value) => JSON.stringify({ ...value, generatedAt: undefined });
|
|
116
|
+
if (semantic(existing) === semantic(receipt))
|
|
117
|
+
return destination;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// A missing or invalid prior receipt is replaced atomically below.
|
|
121
|
+
}
|
|
122
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
123
|
+
try {
|
|
124
|
+
await writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, {
|
|
125
|
+
encoding: 'utf8', mode: 0o600, flag: 'wx',
|
|
126
|
+
});
|
|
127
|
+
await rename(temporary, destination);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
return destination;
|
|
134
|
+
}
|
|
135
|
+
export async function diagnoseProviderTransformationReceipt(options) {
|
|
136
|
+
const receiptPath = providerTransformationReceiptPath(options.projectRoot, options.provider, options.scope);
|
|
137
|
+
let receipt;
|
|
138
|
+
try {
|
|
139
|
+
receipt = validateProviderTransformationReceipt(JSON.parse(await readFile(receiptPath, 'utf8')));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const missing = error.code === 'ENOENT';
|
|
143
|
+
return {
|
|
144
|
+
status: missing ? 'missing-receipt' : 'drifted',
|
|
145
|
+
receiptPath,
|
|
146
|
+
checkedOutputs: 0,
|
|
147
|
+
findings: [{
|
|
148
|
+
kind: 'missing-receipt',
|
|
149
|
+
message: missing
|
|
150
|
+
? 'No transformation receipt exists for this provider deployment.'
|
|
151
|
+
: `The transformation receipt is unreadable or invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
152
|
+
}],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const findings = [];
|
|
156
|
+
if (options.source?.verification === 'failed') {
|
|
157
|
+
findings.push({ kind: 'source-verification-failure', message: 'The canonical source subject did not verify.' });
|
|
158
|
+
}
|
|
159
|
+
else if (options.source && options.source.sha256 !== receipt.source.sha256) {
|
|
160
|
+
findings.push({
|
|
161
|
+
kind: 'stale-output',
|
|
162
|
+
message: 'The deployed output was generated from an older canonical source subject.',
|
|
163
|
+
expected: options.source.sha256,
|
|
164
|
+
actual: receipt.source.sha256,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (options.transformer) {
|
|
168
|
+
for (const key of ['id', 'version', 'providerAdapter', 'providerAdapterVersion']) {
|
|
169
|
+
if (options.transformer[key] !== receipt.transformer[key]) {
|
|
170
|
+
findings.push({
|
|
171
|
+
kind: 'transformation-mismatch',
|
|
172
|
+
message: `The recorded ${key} does not match the active transformer.`,
|
|
173
|
+
expected: options.transformer[key],
|
|
174
|
+
actual: receipt.transformer[key],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const output of receipt.outputs) {
|
|
180
|
+
try {
|
|
181
|
+
const bytes = await readRegularOutput(options.outputRoot ?? options.projectRoot, output.path);
|
|
182
|
+
const actual = digest(bytes);
|
|
183
|
+
if (actual !== output.sha256 || bytes.byteLength !== output.bytes) {
|
|
184
|
+
findings.push({
|
|
185
|
+
kind: 'user-modification',
|
|
186
|
+
path: output.path,
|
|
187
|
+
message: 'A generated provider output differs from its recorded receipt.',
|
|
188
|
+
expected: output.sha256,
|
|
189
|
+
actual,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const unsafeType = error instanceof Error
|
|
195
|
+
&& (error.message.includes('must be a regular file') || error.message.includes('outside its configured root'));
|
|
196
|
+
findings.push({
|
|
197
|
+
kind: unsafeType ? 'user-modification' : 'stale-output',
|
|
198
|
+
path: output.path,
|
|
199
|
+
message: unsafeType
|
|
200
|
+
? 'A receipt-bound output was replaced by a symbolic link, non-regular file, or path outside its configured root.'
|
|
201
|
+
: error.code === 'ENOENT'
|
|
202
|
+
? 'A receipt-bound generated output is missing (partial deployment).'
|
|
203
|
+
: `A receipt-bound generated output could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
204
|
+
expected: output.sha256,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
status: findings.length === 0 ? 'verified' : 'drifted',
|
|
210
|
+
receiptPath,
|
|
211
|
+
checkedOutputs: receipt.outputs.length,
|
|
212
|
+
findings,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=transformation-receipt.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const DEFAULT_RESOURCE_BASE_URL = "https://releases.aiwg.io";
|
|
2
2
|
export declare const AIWG_RELEASE_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA8BsJ2vjuHBReexz328sknfL7MKUtxynX6MGfqFVMD38=\n-----END PUBLIC KEY-----";
|
|
3
|
+
declare const ARTIFACT_ATTESTATION_MEDIA_TYPE = "application/vnd.aiwg.artifact-attestation.v1+json";
|
|
3
4
|
export type ResourceSource = "local" | "web" | "auto";
|
|
4
5
|
export type ResourceSelector = {
|
|
5
6
|
kind: "exact";
|
|
@@ -61,6 +62,14 @@ export interface VerifiedReleaseDescriptor {
|
|
|
61
62
|
path: string;
|
|
62
63
|
size: number;
|
|
63
64
|
sha256: string;
|
|
65
|
+
mediaType?: string;
|
|
66
|
+
attestation?: VerifiedReleaseAttestationDescriptor;
|
|
67
|
+
}
|
|
68
|
+
export interface VerifiedReleaseAttestationDescriptor {
|
|
69
|
+
path: string;
|
|
70
|
+
size: number;
|
|
71
|
+
sha256: string;
|
|
72
|
+
mediaType: typeof ARTIFACT_ATTESTATION_MEDIA_TYPE;
|
|
64
73
|
}
|
|
65
74
|
export interface VerifiedWebRelease {
|
|
66
75
|
selector: string;
|
|
@@ -79,6 +88,7 @@ export interface VerifiedWebRelease {
|
|
|
79
88
|
fortemiExportSha256: string;
|
|
80
89
|
fortemiExportSize: number;
|
|
81
90
|
channelSequence?: number;
|
|
91
|
+
channelExpiresAt?: string;
|
|
82
92
|
descriptors: ReadonlyMap<string, VerifiedReleaseDescriptor>;
|
|
83
93
|
}
|
|
84
94
|
export interface VerifiedRawResourceOptions extends Pick<WebReleaseOptions, "baseUrl" | "fetcher" | "credentialProvider" | "allowInsecureLoopbackHttp"> {
|
|
@@ -103,4 +113,5 @@ export declare function loadResourceTrustRootFile(pathname: string): Buffer;
|
|
|
103
113
|
export declare function resolveWebRelease(options?: WebReleaseOptions): Promise<VerifiedWebRelease>;
|
|
104
114
|
export declare function fetchVerifiedRawResource(release: VerifiedWebRelease, resourcePath: string, options?: VerifiedRawResourceOptions): Promise<Buffer>;
|
|
105
115
|
export declare function createWebReleaseTestOptions(baseUrl: string, overrides?: Omit<WebReleaseOptions, "baseUrl" | "allowInsecureLoopbackHttp">): WebReleaseOptions;
|
|
116
|
+
export {};
|
|
106
117
|
//# sourceMappingURL=web-release.d.ts.map
|
|
@@ -12,6 +12,10 @@ const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
12
12
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
13
13
|
const DIGEST_SELECTOR_PATTERN = /^sha256:([0-9a-f]{64})$/;
|
|
14
14
|
const SIGNATURE_PATTERN = /^(?:[A-Za-z0-9+/]{4}){21}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/;
|
|
15
|
+
const RFC3339_UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
16
|
+
const MEDIA_TYPE_PATTERN = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$/;
|
|
17
|
+
const ARTIFACT_ATTESTATION_MEDIA_TYPE = "application/vnd.aiwg.artifact-attestation.v1+json";
|
|
18
|
+
const ARTIFACT_ATTESTATION_SUFFIX = ".aiwg-attestation.json";
|
|
15
19
|
const RELEASE_MANIFEST_SCHEMAS = new Set([
|
|
16
20
|
"aiwg.resource-manifest/v1",
|
|
17
21
|
"aiwg.resource-manifest/v2",
|
|
@@ -117,7 +121,7 @@ function assertSafeRelativePath(value, label) {
|
|
|
117
121
|
throw new Error(`${label} is not a safe relative path`);
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
|
-
function
|
|
124
|
+
function descriptorBaseFrom(value, descriptorPath) {
|
|
121
125
|
if (!isRecord(value))
|
|
122
126
|
throw new Error(`release descriptor for ${descriptorPath} must be an object`);
|
|
123
127
|
assertSafeRelativePath(value.path, `release descriptor path for ${descriptorPath}`);
|
|
@@ -127,7 +131,34 @@ function descriptorFrom(value, descriptorPath) {
|
|
|
127
131
|
if (typeof value.sha256 !== "string" || !SHA256_PATTERN.test(value.sha256)) {
|
|
128
132
|
throw new Error(`release descriptor digest for ${value.path} is invalid`);
|
|
129
133
|
}
|
|
130
|
-
|
|
134
|
+
if (value.mediaType !== undefined && (typeof value.mediaType !== "string" || !MEDIA_TYPE_PATTERN.test(value.mediaType))) {
|
|
135
|
+
throw new Error(`release descriptor mediaType for ${value.path} is invalid`);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
path: value.path,
|
|
139
|
+
size: value.size,
|
|
140
|
+
sha256: value.sha256,
|
|
141
|
+
...(typeof value.mediaType === "string" ? { mediaType: value.mediaType } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function descriptorFrom(value, descriptorPath) {
|
|
145
|
+
const descriptor = descriptorBaseFrom(value, descriptorPath);
|
|
146
|
+
if (!isRecord(value) || value.attestation === undefined)
|
|
147
|
+
return descriptor;
|
|
148
|
+
if (!descriptor.mediaType) {
|
|
149
|
+
throw new Error(`release descriptor ${descriptor.path} with an attestation must declare mediaType`);
|
|
150
|
+
}
|
|
151
|
+
const attestation = descriptorBaseFrom(value.attestation, `${descriptor.path} attestation`);
|
|
152
|
+
if (attestation.path !== `${descriptor.path}${ARTIFACT_ATTESTATION_SUFFIX}`) {
|
|
153
|
+
throw new Error(`release attestation descriptor for ${descriptor.path} is not adjacent to its artifact`);
|
|
154
|
+
}
|
|
155
|
+
if (attestation.mediaType !== ARTIFACT_ATTESTATION_MEDIA_TYPE) {
|
|
156
|
+
throw new Error(`release attestation descriptor for ${descriptor.path} has an invalid mediaType`);
|
|
157
|
+
}
|
|
158
|
+
if (isRecord(value.attestation) && value.attestation.attestation !== undefined) {
|
|
159
|
+
throw new Error(`release attestation descriptor for ${descriptor.path} must not contain another attestation`);
|
|
160
|
+
}
|
|
161
|
+
return { ...descriptor, attestation: attestation };
|
|
131
162
|
}
|
|
132
163
|
function validateReleaseManifest(value, version) {
|
|
133
164
|
if (!isRecord(value) || typeof value.schemaVersion !== "string" || !RELEASE_MANIFEST_SCHEMAS.has(value.schemaVersion)) {
|
|
@@ -157,12 +188,24 @@ function validateReleaseManifest(value, version) {
|
|
|
157
188
|
if (descriptors.has(descriptor.path))
|
|
158
189
|
throw new Error(`duplicate release descriptor path: ${descriptor.path}`);
|
|
159
190
|
descriptors.set(descriptor.path, descriptor);
|
|
191
|
+
if (descriptor.attestation) {
|
|
192
|
+
if (descriptors.has(descriptor.attestation.path)) {
|
|
193
|
+
throw new Error(`duplicate release descriptor path: ${descriptor.attestation.path}`);
|
|
194
|
+
}
|
|
195
|
+
descriptors.set(descriptor.attestation.path, descriptor.attestation);
|
|
196
|
+
}
|
|
160
197
|
};
|
|
161
198
|
for (const bundle of value.bundles) {
|
|
162
199
|
if (!isRecord(bundle) || typeof bundle.filename !== "string" || !/^[a-z0-9-]+\.tar\.zst$/.test(bundle.filename)) {
|
|
163
200
|
throw new Error("release manifest contains an unsafe bundle filename");
|
|
164
201
|
}
|
|
165
|
-
add(descriptorFrom({
|
|
202
|
+
add(descriptorFrom({
|
|
203
|
+
path: `bundles/${bundle.filename}`,
|
|
204
|
+
size: bundle.size,
|
|
205
|
+
sha256: bundle.sha256,
|
|
206
|
+
mediaType: bundle.mediaType,
|
|
207
|
+
attestation: bundle.attestation,
|
|
208
|
+
}, bundle.filename));
|
|
166
209
|
}
|
|
167
210
|
for (const file of value.files)
|
|
168
211
|
add(descriptorFrom(file, "file"));
|
|
@@ -187,6 +230,16 @@ function validateChannelManifest(value, channel) {
|
|
|
187
230
|
if (typeof value.releaseManifestSha256 !== "string" || !SHA256_PATTERN.test(value.releaseManifestSha256)) {
|
|
188
231
|
throw new Error(`channel ${channel} has an invalid release manifest digest`);
|
|
189
232
|
}
|
|
233
|
+
if (value.expiresAt !== undefined) {
|
|
234
|
+
if (typeof value.expiresAt !== "string" ||
|
|
235
|
+
!RFC3339_UTC_PATTERN.test(value.expiresAt) ||
|
|
236
|
+
!Number.isFinite(Date.parse(value.expiresAt))) {
|
|
237
|
+
throw new Error(`channel ${channel} has an invalid expiry`);
|
|
238
|
+
}
|
|
239
|
+
if (Date.parse(value.expiresAt) <= Date.now()) {
|
|
240
|
+
throw new Error(`channel ${channel} signed metadata has expired`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
190
243
|
return value;
|
|
191
244
|
}
|
|
192
245
|
function validateVersionIndex(value) {
|
|
@@ -1054,7 +1107,8 @@ export async function resolveWebRelease(options = {}) {
|
|
|
1054
1107
|
const cached = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
|
|
1055
1108
|
if (!cached)
|
|
1056
1109
|
throw new Error(`AIWG resource channel ${selector.value} is not cached; offline mode cannot fetch it`);
|
|
1057
|
-
|
|
1110
|
+
const release = resolveOfflineExact(cacheRoot, selector, cached.manifest.version, publicKeyPem, base, cached.manifest.releaseManifestSha256, cached.manifest.sequence);
|
|
1111
|
+
return cached.manifest.expiresAt ? { ...release, channelExpiresAt: cached.manifest.expiresAt } : release;
|
|
1058
1112
|
}
|
|
1059
1113
|
const fetcher = authorize;
|
|
1060
1114
|
if (!fetcher)
|
|
@@ -1071,7 +1125,8 @@ export async function resolveWebRelease(options = {}) {
|
|
|
1071
1125
|
if (fetched.notModified) {
|
|
1072
1126
|
cacheChannel(cacheRoot, prior.manifest, prior.bytes, prior.signatureBytes, prior.digest, fetched.validator);
|
|
1073
1127
|
options.onDiagnostic?.({ resource: "channel", outcome: "conditional-hit", validator: prior.validator.etag ? "etag" : "last-modified" });
|
|
1074
|
-
|
|
1128
|
+
const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, prior.manifest.version, publicKeyPem, prior.manifest.releaseManifestSha256, prior.manifest.sequence);
|
|
1129
|
+
return prior.manifest.expiresAt ? { ...release, channelExpiresAt: prior.manifest.expiresAt } : release;
|
|
1075
1130
|
}
|
|
1076
1131
|
const channelBytes = fetched.bytes;
|
|
1077
1132
|
const channelSignatureBytes = await fetchBytes(fetcher, resourceUrl(base, `${channelPrefix}.sig`), `channel ${selector.value} signature`, MAX_SIGNATURE_BYTES);
|
|
@@ -1090,7 +1145,7 @@ export async function resolveWebRelease(options = {}) {
|
|
|
1090
1145
|
const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, channel.version, publicKeyPem, channel.releaseManifestSha256, channel.sequence);
|
|
1091
1146
|
cacheChannel(cacheRoot, channel, channelBytes, channelSignatureBytes, channelDigest, fetched.validator);
|
|
1092
1147
|
options.onDiagnostic?.({ resource: "channel", outcome: prior?.validator ? "revalidated" : "unconditional", validator: fetched.validator?.etag ? "etag" : fetched.validator?.lastModified ? "last-modified" : "none" });
|
|
1093
|
-
return release;
|
|
1148
|
+
return channel.expiresAt ? { ...release, channelExpiresAt: channel.expiresAt } : release;
|
|
1094
1149
|
}
|
|
1095
1150
|
export async function fetchVerifiedRawResource(release, resourcePath, options = {}) {
|
|
1096
1151
|
assertSafeRelativePath(resourcePath, "raw resource path");
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, sign as signBytes, } from 'node:crypto';
|
|
2
|
+
import { canonicalJson, dssePae, publicKeyFingerprint, sha256 } from './artifact-trust.js';
|
|
3
|
+
export const ARTIFACT_ATTESTATION_MEDIA_TYPE = 'application/vnd.aiwg.artifact-attestation.v1+json';
|
|
4
|
+
export const ARTIFACT_PROVENANCE_PREDICATE_TYPE = 'https://aiwg.io/attestations/artifact-provenance/v1';
|
|
5
|
+
export const IN_TOTO_STATEMENT_V1 = 'https://in-toto.io/Statement/v1';
|
|
6
|
+
export const DSSE_IN_TOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
7
|
+
function assertNonEmpty(value, label) {
|
|
8
|
+
if (!value)
|
|
9
|
+
throw new Error(`${label} must not be empty`);
|
|
10
|
+
}
|
|
11
|
+
function assertTimestamp(value, label) {
|
|
12
|
+
const parsed = Date.parse(value);
|
|
13
|
+
if (!Number.isFinite(parsed))
|
|
14
|
+
throw new Error(`${label} must be an RFC 3339 date-time`);
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
function assertSha256(value, label) {
|
|
18
|
+
if (!/^[a-f0-9]{64}$/.test(value))
|
|
19
|
+
throw new Error(`${label} must be a lowercase SHA-256 digest`);
|
|
20
|
+
}
|
|
21
|
+
function validateDescriptor(descriptor, label) {
|
|
22
|
+
assertNonEmpty(descriptor.name, `${label}.name`);
|
|
23
|
+
assertSha256(descriptor.digest.sha256, `${label}.digest.sha256`);
|
|
24
|
+
}
|
|
25
|
+
/** Build the exact canonical in-toto bytes that DSSE signs. */
|
|
26
|
+
export function createArtifactProvenanceStatement(options) {
|
|
27
|
+
assertNonEmpty(options.artifact.name, 'artifact.name');
|
|
28
|
+
assertNonEmpty(options.assetType, 'assetType');
|
|
29
|
+
assertNonEmpty(options.publisher.id, 'publisher.id');
|
|
30
|
+
assertNonEmpty(options.publisher.namespace, 'publisher.namespace');
|
|
31
|
+
assertNonEmpty(options.publication.version, 'publication.version');
|
|
32
|
+
assertNonEmpty(options.publication.channel, 'publication.channel');
|
|
33
|
+
if (!Number.isSafeInteger(options.publication.sequence) || options.publication.sequence < 1) {
|
|
34
|
+
throw new Error('publication.sequence must be a positive safe integer');
|
|
35
|
+
}
|
|
36
|
+
const issuedAt = assertTimestamp(options.issuedAt, 'issuedAt');
|
|
37
|
+
const notBefore = options.notBefore ? assertTimestamp(options.notBefore, 'notBefore') : undefined;
|
|
38
|
+
const expiresAt = options.expiresAt ? assertTimestamp(options.expiresAt, 'expiresAt') : undefined;
|
|
39
|
+
if (notBefore !== undefined && expiresAt !== undefined && expiresAt <= notBefore) {
|
|
40
|
+
throw new Error('expiresAt must follow notBefore');
|
|
41
|
+
}
|
|
42
|
+
if (expiresAt !== undefined && expiresAt <= issuedAt)
|
|
43
|
+
throw new Error('expiresAt must follow issuedAt');
|
|
44
|
+
for (const [index, dependency] of (options.dependencies ?? []).entries()) {
|
|
45
|
+
validateDescriptor(dependency, `dependencies[${index}]`);
|
|
46
|
+
}
|
|
47
|
+
for (const [index, material] of (options.derivation?.materials ?? []).entries()) {
|
|
48
|
+
if (material.name !== undefined)
|
|
49
|
+
assertNonEmpty(material.name, `derivation.materials[${index}].name`);
|
|
50
|
+
assertNonEmpty(material.uri, `derivation.materials[${index}].uri`);
|
|
51
|
+
if (material.mediaType !== undefined)
|
|
52
|
+
assertNonEmpty(material.mediaType, `derivation.materials[${index}].mediaType`);
|
|
53
|
+
assertSha256(material.digest.sha256, `derivation.materials[${index}].digest.sha256`);
|
|
54
|
+
}
|
|
55
|
+
const subject = {
|
|
56
|
+
name: options.artifact.name,
|
|
57
|
+
...(options.artifact.uri ? { uri: options.artifact.uri } : {}),
|
|
58
|
+
...(options.artifact.mediaType ? { mediaType: options.artifact.mediaType } : {}),
|
|
59
|
+
digest: { sha256: sha256(options.artifact.bytes) },
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
_type: IN_TOTO_STATEMENT_V1,
|
|
63
|
+
subject: [subject],
|
|
64
|
+
predicateType: ARTIFACT_PROVENANCE_PREDICATE_TYPE,
|
|
65
|
+
predicate: {
|
|
66
|
+
schemaVersion: 'aiwg.artifact-provenance.v1',
|
|
67
|
+
assetType: options.assetType,
|
|
68
|
+
publisher: options.publisher,
|
|
69
|
+
publication: options.publication,
|
|
70
|
+
issuedAt: options.issuedAt,
|
|
71
|
+
...(options.notBefore ? { notBefore: options.notBefore } : {}),
|
|
72
|
+
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
|
73
|
+
...(options.derivation ? { derivation: options.derivation } : {}),
|
|
74
|
+
...(options.provenanceGraph ? { provenanceGraph: options.provenanceGraph } : {}),
|
|
75
|
+
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Create a portable public-key DSSE attestation over exact canonical payload bytes. */
|
|
80
|
+
export function createArtifactAttestation(options) {
|
|
81
|
+
const privateKey = options.privateKey instanceof Object && 'type' in options.privateKey
|
|
82
|
+
? options.privateKey
|
|
83
|
+
: createPrivateKey(options.privateKey);
|
|
84
|
+
if (privateKey.asymmetricKeyType !== 'ed25519')
|
|
85
|
+
throw new Error('artifact attestation key must be Ed25519');
|
|
86
|
+
const publicKey = createPublicKey(privateKey);
|
|
87
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
88
|
+
const statement = createArtifactProvenanceStatement(options);
|
|
89
|
+
const payload = Buffer.from(canonicalJson(statement), 'utf8');
|
|
90
|
+
const signature = signBytes(null, dssePae(DSSE_IN_TOTO_PAYLOAD_TYPE, payload), privateKey);
|
|
91
|
+
return {
|
|
92
|
+
mediaType: ARTIFACT_ATTESTATION_MEDIA_TYPE,
|
|
93
|
+
envelope: {
|
|
94
|
+
payloadType: DSSE_IN_TOTO_PAYLOAD_TYPE,
|
|
95
|
+
payload: payload.toString('base64'),
|
|
96
|
+
signatures: [{ keyid: publicKeyFingerprint(publicKeyPem), sig: signature.toString('base64') }],
|
|
97
|
+
},
|
|
98
|
+
verificationMaterial: {
|
|
99
|
+
kind: 'public-key',
|
|
100
|
+
algorithm: 'ed25519',
|
|
101
|
+
publicKey: publicKeyPem,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function serializeArtifactAttestation(attestation) {
|
|
106
|
+
return Buffer.from(`${canonicalJson(attestation)}\n`, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
export function describeAttestationSidecar(artifactPath, attestationBytes) {
|
|
109
|
+
assertNonEmpty(artifactPath, 'artifactPath');
|
|
110
|
+
return {
|
|
111
|
+
path: `${artifactPath}.aiwg-attestation.json`,
|
|
112
|
+
sha256: sha256(attestationBytes),
|
|
113
|
+
bytes: attestationBytes.byteLength,
|
|
114
|
+
mediaType: ARTIFACT_ATTESTATION_MEDIA_TYPE,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=artifact-attestation.js.map
|