@ellistevo/openclaw-secure 1.0.0 → 1.1.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/README.md CHANGED
@@ -106,8 +106,86 @@ openclaw-secure audit
106
106
  | `sign` | Sign manifest with your key |
107
107
  | `verify` | Verify manifest signature |
108
108
  | `audit` | Calculate trust score |
109
+ | `attest` | Add an auditor attestation (vouch for a skill) |
110
+ | `isnad` | Show the chain of trust (author → auditors) |
109
111
  | `show-key` | Display your public key |
110
112
 
113
+ ## Attestation Chains (Isnad) 🆕
114
+
115
+ Signing proves WHO wrote a skill. Attestations prove WHO REVIEWED it.
116
+
117
+ An **isnad** (from Arabic: سند, "chain of transmission") is a chain of trust showing:
118
+ 1. Who authored the skill
119
+ 2. Who audited/reviewed it
120
+ 3. Who vouches for it
121
+
122
+ ### Add an attestation (as an auditor)
123
+
124
+ ```bash
125
+ # Review the skill, then attest it
126
+ openclaw-secure attest --name "YourAuditorName" --type security_audit --notes "Reviewed code, no malicious patterns"
127
+ ```
128
+
129
+ ### View the chain of trust
130
+
131
+ ```bash
132
+ openclaw-secure isnad --verify
133
+ # 📜 Chain of Trust (Isnad):
134
+ # Provenance chain for this skill
135
+ #
136
+ # ├── AUTHOR: SkillAuthor ✓ verified
137
+ # │ Key: abc123...
138
+ # │ Time: 2026-02-05T...
139
+ #
140
+ # └── AUDITOR: SecurityExpert ✓ verified
141
+ # Key: def456...
142
+ # Time: 2026-02-06T...
143
+ # Type: security_audit
144
+ # Notes: Reviewed code, no malicious patterns
145
+ ```
146
+
147
+ ### Trust scoring with attestations
148
+
149
+ Attestations **reduce risk scores**:
150
+
151
+ | Attestation Type | Score Bonus |
152
+ |------------------|-------------|
153
+ | `security_audit` | -20 points |
154
+ | `code_review` | -15 points |
155
+ | `endorsement` | -10 points |
156
+ | From trusted auditor | -10 extra |
157
+
158
+ A skill with Grade C (45 points) + one security audit = Grade B (25 points).
159
+
160
+ ### Programmatic attestation
161
+
162
+ ```javascript
163
+ const {
164
+ createAttestation,
165
+ addAttestation,
166
+ verifyAttestation,
167
+ verifyAllAttestations,
168
+ getIsnad
169
+ } = require('openclaw-secure');
170
+
171
+ // Create attestation
172
+ const attestation = createAttestation(signedManifest, auditorSecretKey, 'AuditorName', {
173
+ type: 'security_audit',
174
+ notes: 'Reviewed and approved'
175
+ });
176
+
177
+ // Add to manifest
178
+ const attested = addAttestation(signedManifest, attestation);
179
+
180
+ // Verify
181
+ const result = verifyAttestation(attestation, attested);
182
+ console.log(result.valid, result.auditor);
183
+
184
+ // View chain
185
+ const chain = getIsnad(attested);
186
+ chain.forEach(link => console.log(link.role, link.identity));
187
+ ```
188
+
111
189
  ## Trust Grades
112
190
 
113
191
  | Grade | Score | Meaning |
@@ -213,7 +291,8 @@ This doesn't sandbox execution (that's OpenClaw's job), but it enables:
213
291
 
214
292
  PRs welcome! Areas we need help:
215
293
  - [ ] Integration with OpenClaw core
216
- - [ ] Trusted key registry
294
+ - [x] Attestation chains (isnad) ✅ v1.1.0
295
+ - [ ] Trusted key registry (public key lookup service)
217
296
  - [ ] Automated auditing tools
218
297
  - [ ] Better sandbox enforcement
219
298
 
package/bin/cli.js CHANGED
@@ -22,7 +22,13 @@ const {
22
22
  formatTrustScore,
23
23
  getKeyFingerprint,
24
24
  defaultPermissions,
25
- defaultResources
25
+ defaultResources,
26
+ // Attestation chain functions
27
+ createAttestation,
28
+ addAttestation,
29
+ verifyAttestation,
30
+ verifyAllAttestations,
31
+ getIsnad
26
32
  } = require('../src');
27
33
 
28
34
  const program = new Command();
@@ -396,4 +402,178 @@ program
396
402
  console.log('\n' + colorize('Share this key so others can verify your signatures.', 'gray'));
397
403
  });
398
404
 
405
+ // === ATTEST COMMAND ===
406
+ program
407
+ .command('attest')
408
+ .description('Add an attestation (vouch for a skill as an auditor)')
409
+ .argument('[path]', 'Path to skill.yaml', 'skill.yaml')
410
+ .option('-k, --key <path>', 'Path to your secret key')
411
+ .option('-n, --name <name>', 'Your auditor name')
412
+ .option('-t, --type <type>', 'Attestation type (security_audit, code_review, endorsement)', 'endorsement')
413
+ .option('--notes <notes>', 'Optional notes about your review')
414
+ .action((manifestPath, options) => {
415
+ if (!fs.existsSync(manifestPath)) {
416
+ error(`File not found: ${manifestPath}`);
417
+ process.exit(1);
418
+ }
419
+
420
+ // Find secret key
421
+ let secretKeyPath = options.key;
422
+ if (!secretKeyPath) {
423
+ const defaultKeyDir = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw-secure');
424
+ secretKeyPath = path.join(defaultKeyDir, 'default.key');
425
+ }
426
+
427
+ if (!fs.existsSync(secretKeyPath)) {
428
+ error(`Secret key not found: ${secretKeyPath}`);
429
+ error('Run: openclaw-secure keygen');
430
+ process.exit(1);
431
+ }
432
+
433
+ try {
434
+ // Read manifest
435
+ const content = fs.readFileSync(manifestPath, 'utf8');
436
+ const manifest = yaml.parse(content);
437
+
438
+ // Must be signed first
439
+ if (!isSigned(manifest)) {
440
+ error('Cannot attest unsigned manifest');
441
+ error('The skill author must sign it first with: openclaw-secure sign');
442
+ process.exit(1);
443
+ }
444
+
445
+ // Verify current signature
446
+ const verifyResult = verifyManifest(manifest);
447
+ if (!verifyResult.valid) {
448
+ error(`Manifest signature is invalid: ${verifyResult.error}`);
449
+ error('Cannot attest a tampered manifest');
450
+ process.exit(1);
451
+ }
452
+
453
+ // Read secret key
454
+ const secretKey = fs.readFileSync(secretKeyPath, 'utf8').trim();
455
+
456
+ // Determine auditor name
457
+ const auditorName = options.name || process.env.USER || 'anonymous-auditor';
458
+
459
+ // Create attestation
460
+ const attestation = createAttestation(manifest, secretKey, auditorName, {
461
+ type: options.type,
462
+ notes: options.notes
463
+ });
464
+
465
+ // Add attestation to manifest
466
+ const attestedManifest = addAttestation(manifest, attestation);
467
+
468
+ // Write back
469
+ const yamlContent = yaml.stringify(attestedManifest);
470
+ fs.writeFileSync(manifestPath, yamlContent);
471
+
472
+ const attestationCount = attestedManifest.attestations.length;
473
+
474
+ success(`Added attestation as "${auditorName}" (${options.type})`);
475
+ info(`Total attestations: ${attestationCount}`);
476
+ if (options.notes) {
477
+ info(`Notes: ${options.notes}`);
478
+ }
479
+
480
+ } catch (err) {
481
+ error(`Attestation failed: ${err.message}`);
482
+ process.exit(1);
483
+ }
484
+ });
485
+
486
+ // === ISNAD (CHAIN OF TRUST) COMMAND ===
487
+ program
488
+ .command('isnad')
489
+ .description('Show the chain of trust (author → auditors)')
490
+ .argument('[path]', 'Path to skill.yaml', 'skill.yaml')
491
+ .option('-v, --verify', 'Verify all signatures in the chain')
492
+ .action((manifestPath, options) => {
493
+ if (!fs.existsSync(manifestPath)) {
494
+ error(`File not found: ${manifestPath}`);
495
+ process.exit(1);
496
+ }
497
+
498
+ try {
499
+ const content = fs.readFileSync(manifestPath, 'utf8');
500
+ const manifest = yaml.parse(content);
501
+
502
+ // Get the isnad (chain)
503
+ const chain = getIsnad(manifest);
504
+
505
+ if (chain.length === 0) {
506
+ warn('No chain of trust found (manifest is unsigned)');
507
+ process.exit(0);
508
+ }
509
+
510
+ console.log('\n' + colorize('📜 Chain of Trust (Isnad):', 'bold'));
511
+ console.log(colorize(' Provenance chain for this skill\n', 'gray'));
512
+
513
+ // Verify if requested
514
+ let authorVerified = false;
515
+ let attestationsVerified = [];
516
+
517
+ if (options.verify) {
518
+ // Verify author signature
519
+ if (manifest.signature) {
520
+ const authorResult = verifyManifest(manifest);
521
+ authorVerified = authorResult.valid;
522
+ }
523
+
524
+ // Verify attestations
525
+ if (manifest.attestations && manifest.attestations.length > 0) {
526
+ const attestResult = verifyAllAttestations(manifest);
527
+ attestationsVerified = attestResult.attestations;
528
+ }
529
+ }
530
+
531
+ // Display chain
532
+ chain.forEach((link, index) => {
533
+ const isLast = index === chain.length - 1;
534
+ const prefix = isLast ? '└──' : '├──';
535
+ const indent = isLast ? ' ' : '│ ';
536
+
537
+ let status = '';
538
+ if (options.verify) {
539
+ if (link.role === 'author') {
540
+ status = authorVerified ? colorize(' ✓ verified', 'green') : colorize(' ✗ invalid', 'red');
541
+ } else {
542
+ const verified = attestationsVerified.find(a => a.auditor === link.identity);
543
+ status = verified ? colorize(' ✓ verified', 'green') : colorize(' ✗ invalid', 'red');
544
+ }
545
+ }
546
+
547
+ const roleColor = link.role === 'author' ? 'cyan' : 'yellow';
548
+ console.log(` ${prefix} ${colorize(link.role.toUpperCase(), roleColor)}: ${link.identity}${status}`);
549
+ console.log(` ${indent} Key: ${link.public_key.substring(0, 16)}...`);
550
+ console.log(` ${indent} Time: ${link.timestamp}`);
551
+ if (link.type) {
552
+ console.log(` ${indent} Type: ${link.type}`);
553
+ }
554
+ if (link.notes) {
555
+ console.log(` ${indent} Notes: ${link.notes}`);
556
+ }
557
+ console.log();
558
+ });
559
+
560
+ // Summary
561
+ const authors = chain.filter(l => l.role === 'author').length;
562
+ const auditors = chain.filter(l => l.role === 'auditor').length;
563
+
564
+ console.log(colorize(' Summary:', 'bold'));
565
+ console.log(` ${authors} author(s), ${auditors} auditor(s)`);
566
+
567
+ if (auditors > 0) {
568
+ info('This skill has been reviewed by third-party auditors');
569
+ } else {
570
+ warn('No third-party auditors have vouched for this skill');
571
+ }
572
+
573
+ } catch (err) {
574
+ error(`Failed to show isnad: ${err.message}`);
575
+ process.exit(1);
576
+ }
577
+ });
578
+
399
579
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ellistevo/openclaw-secure",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Security toolkit for OpenClaw skills - signing, manifests, and verification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -15,7 +15,13 @@ const {
15
15
  verifyManifest,
16
16
  isSigned,
17
17
  getSignableContent,
18
- getKeyFingerprint
18
+ getKeyFingerprint,
19
+ // Attestation chain (isnad) functions
20
+ createAttestation,
21
+ addAttestation,
22
+ verifyAttestation,
23
+ verifyAllAttestations,
24
+ getIsnad
19
25
  } = require('./signing');
20
26
  const {
21
27
  calculateTrustScore,
@@ -47,6 +53,13 @@ module.exports = {
47
53
  getSignableContent,
48
54
  getKeyFingerprint,
49
55
 
56
+ // Attestation chains (isnad)
57
+ createAttestation,
58
+ addAttestation,
59
+ verifyAttestation,
60
+ verifyAllAttestations,
61
+ getIsnad,
62
+
50
63
  // Trust scoring
51
64
  calculateTrustScore,
52
65
  scoreToGrade,
package/src/signing.js CHANGED
@@ -182,6 +182,196 @@ function getKeyFingerprint(publicKeyBase64) {
182
182
  return hash.substring(0, 16);
183
183
  }
184
184
 
185
+ // ============================================================================
186
+ // ATTESTATION CHAINS (ISNAD)
187
+ // Allows auditors to vouch for skills, creating a chain of trust
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Create an attestation for a signed manifest
192
+ * An attestation is a signature over: content_hash + author_signature
193
+ * This creates a chain: content → author → auditor(s)
194
+ *
195
+ * @param {Object} manifest - The signed manifest to attest
196
+ * @param {string} auditorSecretKey - Base64-encoded auditor secret key
197
+ * @param {string} auditorName - Name/identity of the auditor
198
+ * @param {Object} options - Additional attestation options
199
+ * @param {string} [options.type] - Attestation type (security_audit, code_review, endorsement)
200
+ * @param {string} [options.notes] - Optional notes about the review
201
+ * @returns {Object} - The attestation object to add to manifest.attestations[]
202
+ */
203
+ function createAttestation(manifest, auditorSecretKey, auditorName, options = {}) {
204
+ // Manifest must be signed first
205
+ if (!manifest.signature || !manifest.signature.signature) {
206
+ throw new Error('Cannot attest unsigned manifest - sign it first');
207
+ }
208
+
209
+ // Create the attestation message: content_hash + author_signature
210
+ // This proves the auditor saw this exact content signed by this exact author
211
+ const attestationMessage = `${manifest.signature.content_hash}|${manifest.signature.signature}`;
212
+ const messageBytes = naclUtil.decodeUTF8(attestationMessage);
213
+
214
+ // Decode auditor's secret key
215
+ const secretKey = naclUtil.decodeBase64(auditorSecretKey);
216
+ const publicKey = secretKey.slice(32);
217
+
218
+ // Sign the attestation
219
+ const signatureBytes = nacl.sign.detached(messageBytes, secretKey);
220
+
221
+ return {
222
+ type: options.type || 'endorsement',
223
+ auditor: auditorName,
224
+ auditor_public_key: naclUtil.encodeBase64(publicKey),
225
+ attestation: naclUtil.encodeBase64(signatureBytes),
226
+ attested_at: new Date().toISOString(),
227
+ notes: options.notes || null,
228
+ // Include what was attested for verification
229
+ attested_content_hash: manifest.signature.content_hash,
230
+ attested_author_signature: manifest.signature.signature
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Add an attestation to a manifest
236
+ * @param {Object} manifest - The manifest to add attestation to
237
+ * @param {Object} attestation - The attestation from createAttestation()
238
+ * @returns {Object} - Manifest with attestation added
239
+ */
240
+ function addAttestation(manifest, attestation) {
241
+ const attestations = manifest.attestations || [];
242
+
243
+ // Check for duplicate attestations from same auditor
244
+ const existingAuditor = attestations.find(a =>
245
+ a.auditor_public_key === attestation.auditor_public_key
246
+ );
247
+ if (existingAuditor) {
248
+ throw new Error(`Attestation from this auditor already exists (${attestation.auditor})`);
249
+ }
250
+
251
+ return {
252
+ ...manifest,
253
+ attestations: [...attestations, attestation]
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Verify an attestation
259
+ * @param {Object} attestation - The attestation to verify
260
+ * @param {Object} manifest - The manifest it attests
261
+ * @returns {Object} - { valid: boolean, error?: string, auditor?: string }
262
+ */
263
+ function verifyAttestation(attestation, manifest) {
264
+ // Check manifest is signed
265
+ if (!manifest.signature || !manifest.signature.signature) {
266
+ return { valid: false, error: 'Manifest is not signed' };
267
+ }
268
+
269
+ // Check attestation matches current manifest
270
+ if (attestation.attested_content_hash !== manifest.signature.content_hash) {
271
+ return {
272
+ valid: false,
273
+ error: 'Attestation is for different content version',
274
+ attestedHash: attestation.attested_content_hash,
275
+ currentHash: manifest.signature.content_hash
276
+ };
277
+ }
278
+
279
+ if (attestation.attested_author_signature !== manifest.signature.signature) {
280
+ return {
281
+ valid: false,
282
+ error: 'Attestation is for different author signature'
283
+ };
284
+ }
285
+
286
+ try {
287
+ // Recreate the attestation message
288
+ const attestationMessage = `${attestation.attested_content_hash}|${attestation.attested_author_signature}`;
289
+ const messageBytes = naclUtil.decodeUTF8(attestationMessage);
290
+
291
+ // Decode auditor's public key and signature
292
+ const publicKey = naclUtil.decodeBase64(attestation.auditor_public_key);
293
+ const signatureBytes = naclUtil.decodeBase64(attestation.attestation);
294
+
295
+ // Verify
296
+ const valid = nacl.sign.detached.verify(messageBytes, signatureBytes, publicKey);
297
+
298
+ if (valid) {
299
+ return {
300
+ valid: true,
301
+ auditor: attestation.auditor,
302
+ type: attestation.type,
303
+ attested_at: attestation.attested_at,
304
+ notes: attestation.notes
305
+ };
306
+ } else {
307
+ return { valid: false, error: 'Attestation signature verification failed' };
308
+ }
309
+ } catch (err) {
310
+ return { valid: false, error: `Attestation verification error: ${err.message}` };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Verify all attestations on a manifest
316
+ * @param {Object} manifest - The manifest with attestations
317
+ * @returns {Object} - { valid: boolean, attestations: Array, errors: Array }
318
+ */
319
+ function verifyAllAttestations(manifest) {
320
+ const attestations = manifest.attestations || [];
321
+
322
+ if (attestations.length === 0) {
323
+ return { valid: true, attestations: [], errors: [], count: 0 };
324
+ }
325
+
326
+ const results = attestations.map((att, i) => ({
327
+ index: i,
328
+ ...verifyAttestation(att, manifest)
329
+ }));
330
+
331
+ const valid = results.filter(r => r.valid);
332
+ const invalid = results.filter(r => !r.valid);
333
+
334
+ return {
335
+ valid: invalid.length === 0,
336
+ attestations: valid,
337
+ errors: invalid,
338
+ count: valid.length
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Get the isnad (chain of transmission) for a manifest
344
+ * Returns the provenance chain: author → auditor(s)
345
+ */
346
+ function getIsnad(manifest) {
347
+ const chain = [];
348
+
349
+ // First link: author
350
+ if (manifest.signature) {
351
+ chain.push({
352
+ role: 'author',
353
+ identity: manifest.signature.signer,
354
+ public_key: manifest.signature.public_key,
355
+ timestamp: manifest.signature.signed_at
356
+ });
357
+ }
358
+
359
+ // Subsequent links: auditors
360
+ const attestations = manifest.attestations || [];
361
+ for (const att of attestations) {
362
+ chain.push({
363
+ role: 'auditor',
364
+ identity: att.auditor,
365
+ public_key: att.auditor_public_key,
366
+ timestamp: att.attested_at,
367
+ type: att.type,
368
+ notes: att.notes
369
+ });
370
+ }
371
+
372
+ return chain;
373
+ }
374
+
185
375
  module.exports = {
186
376
  generateKeyPair,
187
377
  computeHash,
@@ -189,5 +379,11 @@ module.exports = {
189
379
  verifyManifest,
190
380
  isSigned,
191
381
  getSignableContent,
192
- getKeyFingerprint
382
+ getKeyFingerprint,
383
+ // Attestation chain (isnad) functions
384
+ createAttestation,
385
+ addAttestation,
386
+ verifyAttestation,
387
+ verifyAllAttestations,
388
+ getIsnad
193
389
  };
package/src/trust.js CHANGED
@@ -38,6 +38,23 @@ const RISK_WEIGHTS = {
38
38
  unverified: 30 // Signature present but not verified
39
39
  };
40
40
 
41
+ /**
42
+ * Attestation bonuses (reduce risk score)
43
+ * More attestations from trusted auditors = lower risk
44
+ */
45
+ const ATTESTATION_BONUSES = {
46
+ // Per valid attestation
47
+ security_audit: -20, // Security audit (highest trust reduction)
48
+ code_review: -15, // Code review
49
+ endorsement: -10, // General endorsement
50
+
51
+ // Caps (can't go below 0 from attestations)
52
+ max_bonus: -50, // Maximum total attestation bonus
53
+
54
+ // Trusted auditor bonus (if public key matches known auditors)
55
+ trusted_auditor: -10 // Additional bonus for known auditors
56
+ };
57
+
41
58
  /**
42
59
  * Sensitive credential patterns
43
60
  */
@@ -54,7 +71,7 @@ const SENSITIVE_CREDENTIAL_PATTERNS = [
54
71
  /**
55
72
  * Calculate risk score for a manifest
56
73
  * @param {Object} manifest - The manifest to score
57
- * @param {Object} options - { signed: boolean, verified: boolean }
74
+ * @param {Object} options - { signed: boolean, verified: boolean, attestationsVerified: array, trustedAuditors: array }
58
75
  * @returns {Object} - { score: number, grade: string, breakdown: object }
59
76
  */
60
77
  function calculateTrustScore(manifest, options = {}) {
@@ -64,7 +81,8 @@ function calculateTrustScore(manifest, options = {}) {
64
81
  shell: 0,
65
82
  credentials: 0,
66
83
  capabilities: 0,
67
- signature: 0
84
+ signature: 0,
85
+ attestations: 0 // Bonus (negative number)
68
86
  };
69
87
 
70
88
  const permissions = manifest.permissions || {};
@@ -140,8 +158,31 @@ function calculateTrustScore(manifest, options = {}) {
140
158
  breakdown.signature += RISK_WEIGHTS.unverified;
141
159
  }
142
160
 
143
- // Calculate total
144
- const totalScore = Object.values(breakdown).reduce((a, b) => a + b, 0);
161
+ // === ATTESTATIONS (BONUS - reduces score) ===
162
+ const attestations = manifest.attestations || [];
163
+ const verifiedAttestations = options.attestationsVerified || [];
164
+ const trustedAuditors = options.trustedAuditors || [];
165
+
166
+ verifiedAttestations.forEach(att => {
167
+ // Get bonus based on attestation type
168
+ const type = att.type || 'endorsement';
169
+ const typeBonus = ATTESTATION_BONUSES[type] || ATTESTATION_BONUSES.endorsement;
170
+ breakdown.attestations += typeBonus;
171
+
172
+ // Extra bonus if auditor is in trusted list
173
+ if (trustedAuditors.includes(att.auditor) ||
174
+ trustedAuditors.includes(att.auditor_public_key)) {
175
+ breakdown.attestations += ATTESTATION_BONUSES.trusted_auditor;
176
+ }
177
+ });
178
+
179
+ // Cap attestation bonus
180
+ if (breakdown.attestations < ATTESTATION_BONUSES.max_bonus) {
181
+ breakdown.attestations = ATTESTATION_BONUSES.max_bonus;
182
+ }
183
+
184
+ // Calculate total (attestations is negative, so it reduces score)
185
+ const totalScore = Math.max(0, Object.values(breakdown).reduce((a, b) => a + b, 0));
145
186
 
146
187
  // Determine grade
147
188
  const grade = scoreToGrade(totalScore);
@@ -150,7 +191,8 @@ function calculateTrustScore(manifest, options = {}) {
150
191
  score: totalScore,
151
192
  grade,
152
193
  breakdown,
153
- summary: generateSummary(breakdown, grade)
194
+ attestationCount: verifiedAttestations.length,
195
+ summary: generateSummary(breakdown, grade, verifiedAttestations.length)
154
196
  };
155
197
  }
156
198
 
@@ -196,7 +238,7 @@ function gradeEmoji(grade) {
196
238
  /**
197
239
  * Generate human-readable summary
198
240
  */
199
- function generateSummary(breakdown, grade) {
241
+ function generateSummary(breakdown, grade, attestationCount = 0) {
200
242
  const concerns = [];
201
243
 
202
244
  if (breakdown.shell > 0) {
@@ -218,6 +260,11 @@ function generateSummary(breakdown, grade) {
218
260
  concerns.push('⚠️ Missing or unverified signature');
219
261
  }
220
262
 
263
+ // Positive notes
264
+ if (breakdown.attestations < 0) {
265
+ concerns.push(`✅ ${attestationCount} verified attestation(s) (${breakdown.attestations} pts)`);
266
+ }
267
+
221
268
  if (concerns.length === 0) {
222
269
  concerns.push('✅ Minimal permissions requested');
223
270
  }
@@ -246,7 +293,7 @@ function getGradeDescription(grade) {
246
293
  * Format trust score for display
247
294
  */
248
295
  function formatTrustScore(result) {
249
- const { score, grade, breakdown, summary } = result;
296
+ const { score, grade, breakdown, summary, attestationCount } = result;
250
297
 
251
298
  let output = `\n${gradeEmoji(grade)} Trust Score: ${grade} (${score} points)\n`;
252
299
  output += ` ${summary.grade_description}\n\n`;
@@ -257,7 +304,11 @@ function formatTrustScore(result) {
257
304
  output += ` - Shell: ${breakdown.shell} pts\n`;
258
305
  output += ` - Credentials: ${breakdown.credentials} pts\n`;
259
306
  output += ` - Capabilities: ${breakdown.capabilities} pts\n`;
260
- output += ` - Signature: ${breakdown.signature} pts\n\n`;
307
+ output += ` - Signature: ${breakdown.signature} pts\n`;
308
+ if (breakdown.attestations !== 0) {
309
+ output += ` - Attestations: ${breakdown.attestations} pts (${attestationCount || 0} verified)\n`;
310
+ }
311
+ output += '\n';
261
312
 
262
313
  if (summary.concerns.length > 0) {
263
314
  output += ` Notes:\n`;
@@ -275,5 +326,6 @@ module.exports = {
275
326
  gradeColor,
276
327
  gradeEmoji,
277
328
  formatTrustScore,
278
- RISK_WEIGHTS
329
+ RISK_WEIGHTS,
330
+ ATTESTATION_BONUSES
279
331
  };
package/test/all.test.js CHANGED
@@ -526,4 +526,351 @@ describe('Integration: Full Workflow', () => {
526
526
  });
527
527
  });
528
528
 
529
+ // === ATTESTATION TESTS ===
530
+
531
+ const {
532
+ createAttestation,
533
+ addAttestation,
534
+ verifyAttestation,
535
+ verifyAllAttestations,
536
+ getIsnad
537
+ } = require('../src');
538
+
539
+ describe('Attestation: Create Attestation', () => {
540
+
541
+ it('should create attestation for signed manifest', () => {
542
+ const keyPair = generateKeyPair();
543
+ const auditorKeyPair = generateKeyPair();
544
+
545
+ const manifest = {
546
+ name: 'test-skill',
547
+ version: '1.0.0',
548
+ author: { name: 'Author' },
549
+ permissions: {}
550
+ };
551
+
552
+ const validation = validateManifest(manifest);
553
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
554
+
555
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor', {
556
+ type: 'security_audit',
557
+ notes: 'Reviewed and approved'
558
+ });
559
+
560
+ assert.strictEqual(attestation.auditor, 'Auditor');
561
+ assert.strictEqual(attestation.type, 'security_audit');
562
+ assert.strictEqual(attestation.notes, 'Reviewed and approved');
563
+ assert.ok(attestation.attestation);
564
+ assert.ok(attestation.attested_at);
565
+ });
566
+
567
+ it('should throw when attesting unsigned manifest', () => {
568
+ const auditorKeyPair = generateKeyPair();
569
+
570
+ const manifest = {
571
+ name: 'unsigned-skill',
572
+ version: '1.0.0',
573
+ author: { name: 'Author' },
574
+ permissions: {}
575
+ };
576
+
577
+ assert.throws(() => {
578
+ createAttestation(manifest, auditorKeyPair.secretKey, 'Auditor');
579
+ }, /Cannot attest unsigned manifest/);
580
+ });
581
+ });
582
+
583
+ describe('Attestation: Add Attestation', () => {
584
+
585
+ it('should add attestation to manifest', () => {
586
+ const keyPair = generateKeyPair();
587
+ const auditorKeyPair = generateKeyPair();
588
+
589
+ const manifest = {
590
+ name: 'test-skill',
591
+ version: '1.0.0',
592
+ author: { name: 'Author' },
593
+ permissions: {}
594
+ };
595
+
596
+ const validation = validateManifest(manifest);
597
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
598
+
599
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor');
600
+ const attested = addAttestation(signed, attestation);
601
+
602
+ assert.ok(attested.attestations);
603
+ assert.strictEqual(attested.attestations.length, 1);
604
+ assert.strictEqual(attested.attestations[0].auditor, 'Auditor');
605
+ });
606
+
607
+ it('should prevent duplicate attestations from same auditor', () => {
608
+ const keyPair = generateKeyPair();
609
+ const auditorKeyPair = generateKeyPair();
610
+
611
+ const manifest = {
612
+ name: 'test-skill',
613
+ version: '1.0.0',
614
+ author: { name: 'Author' },
615
+ permissions: {}
616
+ };
617
+
618
+ const validation = validateManifest(manifest);
619
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
620
+
621
+ const attestation1 = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor');
622
+ const attested = addAttestation(signed, attestation1);
623
+
624
+ const attestation2 = createAttestation(attested, auditorKeyPair.secretKey, 'Auditor');
625
+
626
+ assert.throws(() => {
627
+ addAttestation(attested, attestation2);
628
+ }, /already exists/);
629
+ });
630
+
631
+ it('should allow multiple attestations from different auditors', () => {
632
+ const keyPair = generateKeyPair();
633
+ const auditor1KeyPair = generateKeyPair();
634
+ const auditor2KeyPair = generateKeyPair();
635
+
636
+ const manifest = {
637
+ name: 'test-skill',
638
+ version: '1.0.0',
639
+ author: { name: 'Author' },
640
+ permissions: {}
641
+ };
642
+
643
+ const validation = validateManifest(manifest);
644
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
645
+
646
+ const attestation1 = createAttestation(signed, auditor1KeyPair.secretKey, 'Auditor1');
647
+ let attested = addAttestation(signed, attestation1);
648
+
649
+ const attestation2 = createAttestation(attested, auditor2KeyPair.secretKey, 'Auditor2');
650
+ attested = addAttestation(attested, attestation2);
651
+
652
+ assert.strictEqual(attested.attestations.length, 2);
653
+ });
654
+ });
655
+
656
+ describe('Attestation: Verify Attestation', () => {
657
+
658
+ it('should verify valid attestation', () => {
659
+ const keyPair = generateKeyPair();
660
+ const auditorKeyPair = generateKeyPair();
661
+
662
+ const manifest = {
663
+ name: 'test-skill',
664
+ version: '1.0.0',
665
+ author: { name: 'Author' },
666
+ permissions: {}
667
+ };
668
+
669
+ const validation = validateManifest(manifest);
670
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
671
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor', {
672
+ type: 'security_audit'
673
+ });
674
+
675
+ const result = verifyAttestation(attestation, signed);
676
+ assert.strictEqual(result.valid, true);
677
+ assert.strictEqual(result.auditor, 'Auditor');
678
+ assert.strictEqual(result.type, 'security_audit');
679
+ });
680
+
681
+ it('should reject attestation for modified manifest', () => {
682
+ const keyPair = generateKeyPair();
683
+ const auditorKeyPair = generateKeyPair();
684
+
685
+ const manifest = {
686
+ name: 'test-skill',
687
+ version: '1.0.0',
688
+ author: { name: 'Author' },
689
+ permissions: {}
690
+ };
691
+
692
+ const validation = validateManifest(manifest);
693
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
694
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor');
695
+
696
+ // Re-sign with different content (simulates modification)
697
+ const modified = { ...signed, name: 'malicious-skill' };
698
+ const reSigned = signManifest(modified, keyPair.secretKey, 'Author');
699
+
700
+ const result = verifyAttestation(attestation, reSigned);
701
+ assert.strictEqual(result.valid, false);
702
+ assert.ok(result.error.includes('different'));
703
+ });
704
+ });
705
+
706
+ describe('Attestation: Verify All Attestations', () => {
707
+
708
+ it('should verify all attestations at once', () => {
709
+ const keyPair = generateKeyPair();
710
+ const auditor1KeyPair = generateKeyPair();
711
+ const auditor2KeyPair = generateKeyPair();
712
+
713
+ const manifest = {
714
+ name: 'test-skill',
715
+ version: '1.0.0',
716
+ author: { name: 'Author' },
717
+ permissions: {}
718
+ };
719
+
720
+ const validation = validateManifest(manifest);
721
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
722
+
723
+ const attestation1 = createAttestation(signed, auditor1KeyPair.secretKey, 'Auditor1', { type: 'security_audit' });
724
+ let attested = addAttestation(signed, attestation1);
725
+
726
+ const attestation2 = createAttestation(attested, auditor2KeyPair.secretKey, 'Auditor2', { type: 'code_review' });
727
+ attested = addAttestation(attested, attestation2);
728
+
729
+ const result = verifyAllAttestations(attested);
730
+ assert.strictEqual(result.valid, true);
731
+ assert.strictEqual(result.count, 2);
732
+ assert.strictEqual(result.attestations.length, 2);
733
+ });
734
+
735
+ it('should return empty for no attestations', () => {
736
+ const keyPair = generateKeyPair();
737
+
738
+ const manifest = {
739
+ name: 'test-skill',
740
+ version: '1.0.0',
741
+ author: { name: 'Author' },
742
+ permissions: {}
743
+ };
744
+
745
+ const validation = validateManifest(manifest);
746
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
747
+
748
+ const result = verifyAllAttestations(signed);
749
+ assert.strictEqual(result.valid, true);
750
+ assert.strictEqual(result.count, 0);
751
+ });
752
+ });
753
+
754
+ describe('Attestation: Get Isnad (Chain)', () => {
755
+
756
+ it('should return chain with author and auditors', () => {
757
+ const keyPair = generateKeyPair();
758
+ const auditorKeyPair = generateKeyPair();
759
+
760
+ const manifest = {
761
+ name: 'test-skill',
762
+ version: '1.0.0',
763
+ author: { name: 'Author' },
764
+ permissions: {}
765
+ };
766
+
767
+ const validation = validateManifest(manifest);
768
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
769
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor');
770
+ const attested = addAttestation(signed, attestation);
771
+
772
+ const chain = getIsnad(attested);
773
+
774
+ assert.strictEqual(chain.length, 2);
775
+ assert.strictEqual(chain[0].role, 'author');
776
+ assert.strictEqual(chain[0].identity, 'Author');
777
+ assert.strictEqual(chain[1].role, 'auditor');
778
+ assert.strictEqual(chain[1].identity, 'Auditor');
779
+ });
780
+
781
+ it('should return empty chain for unsigned manifest', () => {
782
+ const manifest = {
783
+ name: 'unsigned-skill',
784
+ version: '1.0.0',
785
+ author: { name: 'Author' },
786
+ permissions: {}
787
+ };
788
+
789
+ const chain = getIsnad(manifest);
790
+ assert.strictEqual(chain.length, 0);
791
+ });
792
+ });
793
+
794
+ describe('Trust Scoring with Attestations', () => {
795
+
796
+ it('should reduce score with verified attestations', () => {
797
+ const keyPair = generateKeyPair();
798
+ const auditorKeyPair = generateKeyPair();
799
+
800
+ const manifest = {
801
+ name: 'test-skill',
802
+ version: '1.0.0',
803
+ author: { name: 'Author' },
804
+ permissions: {
805
+ network: { allow: ['*'] } // Risky permission
806
+ }
807
+ };
808
+
809
+ const validation = validateManifest(manifest);
810
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
811
+
812
+ // Score without attestation
813
+ const scoreWithout = calculateTrustScore(signed, { signed: true, verified: true });
814
+
815
+ // Add attestation
816
+ const attestation = createAttestation(signed, auditorKeyPair.secretKey, 'Auditor', {
817
+ type: 'security_audit'
818
+ });
819
+ const attested = addAttestation(signed, attestation);
820
+
821
+ // Verify attestation
822
+ const verifyResult = verifyAttestation(attestation, attested);
823
+
824
+ // Score with attestation
825
+ const scoreWith = calculateTrustScore(attested, {
826
+ signed: true,
827
+ verified: true,
828
+ attestationsVerified: [verifyResult]
829
+ });
830
+
831
+ assert.ok(scoreWith.score < scoreWithout.score, 'Attested score should be lower');
832
+ assert.strictEqual(scoreWith.attestationCount, 1);
833
+ });
834
+
835
+ it('should give extra bonus for trusted auditors', () => {
836
+ const keyPair = generateKeyPair();
837
+ const trustedAuditorKeyPair = generateKeyPair();
838
+
839
+ const manifest = {
840
+ name: 'test-skill',
841
+ version: '1.0.0',
842
+ author: { name: 'Author' },
843
+ permissions: {
844
+ shell: { allowed: true, commands: ['echo'] }
845
+ }
846
+ };
847
+
848
+ const validation = validateManifest(manifest);
849
+ const signed = signManifest(validation.manifest, keyPair.secretKey, 'Author');
850
+
851
+ const attestation = createAttestation(signed, trustedAuditorKeyPair.secretKey, 'TrustedAuditor', {
852
+ type: 'security_audit'
853
+ });
854
+ const attested = addAttestation(signed, attestation);
855
+ const verifyResult = verifyAttestation(attestation, attested);
856
+
857
+ // Score without trusted list
858
+ const scoreNormal = calculateTrustScore(attested, {
859
+ signed: true,
860
+ verified: true,
861
+ attestationsVerified: [verifyResult]
862
+ });
863
+
864
+ // Score with trusted list
865
+ const scoreTrusted = calculateTrustScore(attested, {
866
+ signed: true,
867
+ verified: true,
868
+ attestationsVerified: [verifyResult],
869
+ trustedAuditors: ['TrustedAuditor']
870
+ });
871
+
872
+ assert.ok(scoreTrusted.score < scoreNormal.score, 'Trusted auditor should give extra bonus');
873
+ });
874
+ });
875
+
529
876
  console.log('Running OpenClaw Secure test suite...\n');