enterprise-auth.test.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import {fileURLToPath} from 'node:url';
  8. import {issue} from '../scripts/enterprise-auth/issue.mjs';
  9. import {exportRecipient} from '../scripts/enterprise-auth/export-recipient.mjs';
  10. import {install,validateDelivery} from '../scripts/enterprise-auth/install.mjs';
  11. import {decryptEnvelope} from '../scripts/enterprise-auth/decrypt-envelope.mjs';
  12. import {executeSecure} from '../skills/ksf-workshop-runtime/scripts/ksf-secure.mjs';
  13. import {APP,COMPANY,AGENT,HOST,SKILL,SCOPES,WRAPPER_SHA,sha,encode} from '../scripts/enterprise-auth/common.mjs';
  14. const ROOT=path.dirname(path.dirname(fileURLToPath(import.meta.url)));
  15. const pair=crypto.generateKeyPairSync('rsa',{modulusLength:3072});
  16. const recipient={schema:'ksf.enterprise-workshop-recipient.v1',publicOnly:true,companyObjectId:COMPANY,appId:APP,
  17. agentId:AGENT,host:HOST,keyDirectory:'/opt/data/ksf-key-receive-20261031-fixture',
  18. publicKeyPem:pair.publicKey.export({type:'spki',format:'pem'}),
  19. publicKeySha256:sha(pair.publicKey.export({type:'spki',format:'der'})),privateKeyExported:false,tokenExported:false};
  20. const write=(file,body)=>{fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});fs.writeFileSync(file,body,{mode:0o600});};
  21. function fixture(t){
  22. const base=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-auth-test-'));
  23. t.after(()=>fs.rmSync(base,{recursive:true,force:true}));
  24. const recipientFile=path.join(base,'recipient.json');write(recipientFile,encode(recipient));
  25. return{base,recipientFile,output:path.join(base,'private-issuance')};
  26. }
  27. function fakeApi(f,options={}){
  28. let row,token;const writes=[],calls=[];
  29. return{
  30. writes,calls,get row(){return row;},get token(){return token;},
  31. request:async(method,route,body)=>{
  32. calls.push([method,route]);
  33. assert.ok(route.startsWith('/classes/KsfServiceCredential'),'no business classes written');
  34. if(method==='GET')return route.includes('?')?{results:row?[structuredClone(row)]:[]}:structuredClone(row);
  35. writes.push([method,structuredClone(body)]);
  36. if(method==='POST'){assert.equal(body.enabled,false);row={...structuredClone(body),objectId:'newCred123'};return{objectId:row.objectId};}
  37. if(method==='PUT'){
  38. assert.ok(fs.existsSync(path.join(f.output,'delivery/FILES.sha256')),'encrypted delivery exists before enable');
  39. if(options.failEnable)throw Object.assign(Error(),{code:'ENABLE_UNCERTAIN'});
  40. Object.assign(row,body);return{updatedAt:'2026-11-10T00:00:00.000Z'};
  41. }
  42. throw Error('unexpected request');
  43. },
  44. invoke:async(name,params,headers)=>{
  45. const candidate=headers['X-Ksf-Service-Token'];
  46. if(!candidate||candidate.startsWith('invalid-control-'))return{http:401,body:{result:{ok:false,errors:[{code:'AUTH_REQUIRED'}]}}};
  47. token=headers['X-Ksf-Service-Token'];assert.equal(sha(token),row.tokenHash);assert.ok(row.enabled);
  48. assert.deepEqual(Object.keys(headers).sort(),['Content-Type','X-Ksf-Service-Token','X-Parse-Application-Id'].sort());
  49. if(name==='ksfGetIngestContract')return{http:200,body:{result:{ok:true,data:{workshop:{version:'ksf.workshop.v1'}}}}};
  50. assert.equal(name,'ksfGetRecord');assert.equal(params.className,'KfSource');
  51. return{http:400,body:{result:{ok:false,errors:[{code:'REFERENCE_NOT_FOUND'}]}}};
  52. }
  53. };
  54. }
  55. function target(f){
  56. const dataDir=path.join(f.base,'target');
  57. write(path.join(dataDir,'.fmode/config/enterprise/current.json'),encode({companyObjectId:COMPANY,appId:APP,provisionStatus:'success'}));
  58. const skill=path.join(dataDir,'skills/ksf-workshop-runtime');
  59. for(const n of ['SKILL.md','scripts/ksf-secure.mjs'])write(path.join(skill,n),fs.readFileSync(path.join(ROOT,'skills/ksf-workshop-runtime',n)));
  60. write(path.join(dataDir,path.posix.basename(recipient.keyDirectory),'recipient-private.pem'),pair.privateKey.export({type:'pkcs8',format:'pem'}));
  61. return{dataDir,skill,hostname:HOST,platform:'linux'};
  62. }
  63. const contractChild=()=>({status:0,stdout:JSON.stringify({ok:true,wrapperSha256:WRAPPER_SHA,credentialLoadedForChild:true,
  64. credentialWrittenToDisk:false,entryResult:{readOnly:true,result:{workshop:{version:'ksf.workshop.v1'}}}}),stderr:''});
  65. test('plan neither creates a service credential nor writes an output directory',async t=>{
  66. const f=fixture(t),api=fakeApi(f),result=await issue({...f,apply:false},api);
  67. assert.equal(result.planOnly,true);assert.deepEqual(result.scopes,SCOPES);
  68. assert.equal(fs.existsSync(f.output),false);assert.equal(api.writes.length,0);
  69. });
  70. test('fresh issuance enables only a recoverable encrypted credential; no plaintext is persisted',async t=>{
  71. const f=fixture(t),api=fakeApi(f),result=await issue({...f,apply:true},api);
  72. assert.equal(result.credentialId,'newCred123');assert.equal(result.authorizationRecordWrites,2);
  73. assert.deepEqual(api.row.scopes,['data:read','source:write','ingest:write']);
  74. assert.equal(api.row.scopes.includes('*'),false);assert.equal(api.row.scopes.includes('data:read-all'),false);
  75. const delivery=validateDelivery(result.deliveryDirectory,result.bundleManifestSha256);
  76. const payload=decryptEnvelope(delivery.envelope,pair.privateKey,{host:HOST,directory:recipient.keyDirectory,publicKeySha256:recipient.publicKeySha256});
  77. assert.equal(payload.credentialId,api.row.objectId);assert.equal(payload.env.KSF_SERVICE_TOKEN,api.token);
  78. for(const item of fs.readdirSync(f.output,{recursive:true,withFileTypes:true})){
  79. if(item.isFile()){
  80. const raw=fs.readFileSync(path.join(item.parentPath,item.name));
  81. assert.equal(raw.includes(Buffer.from(api.token)),false);assert.equal(raw.includes(Buffer.from('-----BEGIN PRIVATE KEY-----')),false);
  82. }
  83. }
  84. assert.equal(JSON.stringify(result).includes(api.token),false);
  85. });
  86. test('repeated issuance returns the same ID with no second create or rotation',async t=>{
  87. const f=fixture(t),api=fakeApi(f);const first=await issue({...f,apply:true},api);
  88. const second=await issue({...f,apply:true},api);
  89. assert.equal(second.credentialId,first.credentialId);assert.equal(second.alreadyIssued,true);assert.equal(api.writes.length,2);
  90. });
  91. test('interrupted enable leaves an encrypted recoverable delivery and stops an ambiguous replay',async t=>{
  92. const f=fixture(t),api=fakeApi(f,{failEnable:true});
  93. await assert.rejects(issue({...f,apply:true},api),{code:'ENABLE_UNCERTAIN'});
  94. assert.equal(api.row.enabled,false);assert.ok(fs.existsSync(path.join(f.output,'delivery/credential-envelope.json')));
  95. await assert.rejects(issue({...f,apply:true},api),{code:'INCOMPLETE_ISSUANCE_REQUIRES_RECONCILIATION'});
  96. assert.equal(api.writes.filter(x=>x[0]==='POST').length,1);
  97. });
  98. test('an existing server credential without its journal is not silently adopted',async t=>{
  99. const f=fixture(t),api=fakeApi(f);await issue({...f,apply:true},api);
  100. fs.renameSync(f.output,f.output+'-saved');
  101. await assert.rejects(issue({...f,apply:true},api),{code:'EXISTING_SERVICE_CREDENTIAL_REQUIRES_RECONCILIATION'});
  102. assert.equal(api.writes.length,2);
  103. });
  104. test('recipient host, enterprise app and public-key substitution fail before an API mutation',async t=>{
  105. for(const change of [{host:'043bb9231c09'},{appId:'FKieRbIoGtmain'},{publicKeySha256:'0'.repeat(64)}]){
  106. const f=fixture(t),api=fakeApi(f);write(f.recipientFile,encode({...recipient,...change}));
  107. await assert.rejects(issue({...f,apply:true},api));assert.equal(api.writes.length,0);assert.equal(api.calls.length,0);
  108. }
  109. });
  110. test('changed delivery manifest is rejected even when its file names remain plausible',async t=>{
  111. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api);
  112. write(path.join(r.deliveryDirectory,'credential-envelope.json'),Buffer.from('{}\n'));
  113. await assert.rejects(issue({...f,apply:true},api),{code:'DELIVERY_FILE_MISMATCH'});
  114. assert.throws(()=>validateDelivery(r.deliveryDirectory,r.bundleManifestSha256),{code:'DELIVERY_FILE_MISMATCH'});
  115. });
  116. test('recipient public export uses the target own key and repeats without changing it',t=>{
  117. const f=fixture(t),deps=target(f),output=path.join(deps.dataDir,'public/recipient.json');
  118. exportRecipient({keyDirectory:recipient.keyDirectory,output},deps);
  119. const first=fs.readFileSync(output);exportRecipient({output},deps);assert.deepEqual(fs.readFileSync(output),first);
  120. assert.equal(JSON.parse(first).publicKeySha256,recipient.publicKeySha256);assert.equal(first.includes(Buffer.from('PRIVATE KEY')),false);
  121. });
  122. test('recipient export can generate its own key without using another agent identity',t=>{
  123. const f=fixture(t),deps=target(f),output=path.join(deps.dataDir,'public/new.json');
  124. exportRecipient({output},deps);const r=JSON.parse(fs.readFileSync(output));
  125. assert.notEqual(r.publicKeySha256,recipient.publicKeySha256);
  126. assert.ok(fs.existsSync(path.join(deps.dataDir,path.posix.basename(r.keyDirectory),'recipient-private.pem')));
  127. });
  128. test('target install plan decrypts successfully without writing the binding',async t=>{
  129. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api),deps=target(f);
  130. const result=await install({bundle:r.deliveryDirectory,manifestSha:r.bundleManifestSha256},deps);
  131. assert.equal(result.planOnly,true);assert.equal(fs.existsSync(path.join(deps.skill,'credential-binding.json')),false);
  132. });
  133. test('installation preserves the frozen wrapper, exercises it and permits identical replay',async t=>{
  134. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api),deps=target(f);let calls=0;
  135. const run={...deps,spawn:(node,argv)=>{calls++;assert.equal(argv[1],'workshop-contract');return contractChild();},
  136. controls:async env=>{assert.equal(env.KSF_SERVICE_TOKEN,api.token);return[{identity:'test-fixture',http:400}];}};
  137. const options={bundle:r.deliveryDirectory,manifestSha:r.bundleManifestSha256,apply:true};
  138. const first=await install(options,run),second=await install(options,run);
  139. assert.equal(first.workshopContractVerified,true);assert.equal(second.alreadyBound,true);assert.equal(calls,2);
  140. assert.equal(sha(fs.readFileSync(path.join(deps.skill,'scripts/ksf-secure.mjs'))),WRAPPER_SHA);
  141. assert.equal(first.saveReadbackVerified,false);
  142. });
  143. test('unknown binding or wrong container cannot be overwritten by the installer',async t=>{
  144. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api),deps=target(f);
  145. const o={bundle:r.deliveryDirectory,manifestSha:r.bundleManifestSha256,apply:true};
  146. await assert.rejects(install(o,{...deps,hostname:'043bb9231c09'}),{code:'WRONG_CONTAINER'});
  147. write(path.join(deps.skill,'credential-binding.json'),Buffer.from('{"keep":"existing"}'));
  148. await assert.rejects(install(o,deps),{code:'EXISTING_BINDING_CHANGED'});
  149. assert.equal(fs.existsSync(path.join(deps.dataDir,'ksf-enterprise-auth-'+r.credentialId)),false);
  150. });
  151. test('installer rejects a different private key before installing a binding',async t=>{
  152. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api),deps=target(f);
  153. const other=crypto.generateKeyPairSync('rsa',{modulusLength:3072});
  154. write(path.join(deps.dataDir,path.posix.basename(recipient.keyDirectory),'recipient-private.pem'),other.privateKey.export({type:'pkcs8',format:'pem'}));
  155. await assert.rejects(install({bundle:r.deliveryDirectory,manifestSha:r.bundleManifestSha256,apply:true},deps),{code:'KEY_FINGERPRINT_MISMATCH'});
  156. assert.equal(fs.existsSync(path.join(deps.skill,'credential-binding.json')),false);
  157. });
  158. test('failed live contract does not claim readiness and an identical install can resume',async t=>{
  159. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api),deps=target(f);
  160. const options={bundle:r.deliveryDirectory,manifestSha:r.bundleManifestSha256,apply:true};
  161. await assert.rejects(install(options,{...deps,spawn:()=>({status:1,stdout:'',stderr:'{}'})}),{code:'FROZEN_ENTRY_CONTRACT_FAILED'});
  162. const resumed=await install(options,{...deps,spawn:contractChild,controls:async()=>[]});
  163. assert.equal(resumed.alreadyBound,true);assert.equal(resumed.workshopContractVerified,true);
  164. });
  165. test('the frozen secure wrapper decrypts the newly issued envelope and passes token only to its child',async t=>{
  166. const f=fixture(t),api=fakeApi(f),r=await issue({...f,apply:true},api);
  167. const delivery=validateDelivery(r.deliveryDirectory,r.bundleManifestSha256),argv=['workshop-contract','--input','/opt/data/input.json','--request-id','test-new-agent'];
  168. const skillDir=path.join(ROOT,'skills/ksf-workshop-runtime');
  169. const entry=path.join(skillDir,'scripts/ksf-workshop.mjs'),entryHash=sha(fs.readFileSync(entry));
  170. let childToken;
  171. const result=await executeSecure(argv,{skillDir,hostname:HOST,readPrivateKey:()=>pair.privateKey,
  172. readBytes:file=>{
  173. if(path.basename(file)==='credential-binding.json')return delivery.files['credential-binding.json'];
  174. if(file===delivery.binding.envelopeFile)return delivery.files['credential-envelope.json'];
  175. if(file===delivery.binding.decryptModule)return delivery.files['decrypt-envelope.mjs'];
  176. return fs.readFileSync(file);
  177. },importDecrypt:async()=>({decryptEnvelope}),spawn:(node,args,options)=>{
  178. childToken=options.env.KSF_SERVICE_TOKEN;
  179. return{status:0,stdout:JSON.stringify({skill:'ksf-workshop-runtime',entry,entrySha256:entryHash,command:'workshop-contract',
  180. invocationId:crypto.randomUUID(),requestId:'test-new-agent',ok:true}),stderr:''};
  181. }});
  182. assert.equal(childToken,api.token);assert.equal(result.credentialWrittenToDisk,false);assert.equal(JSON.stringify(result).includes(api.token),false);
  183. });