install.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import os from 'node:os';
  4. import crypto from 'node:crypto';
  5. import {spawnSync} from 'node:child_process';
  6. import {fileURLToPath} from 'node:url';
  7. import {decryptEnvelope,runReadControls} from './decrypt-envelope.mjs';
  8. import {COMPANY,APP,AGENT,HOST,SKILL,PURPOSE,WRAPPER_SHA,DECRYPTOR_SHA,SCOPES,DELIVERY_FILES,
  9. sha,encode,need,plain,privateDirectory,readPrivate,readDelivery,writeNew,argsOf,errorReceipt} from './common.mjs';
  10. const self=fileURLToPath(import.meta.url);
  11. export function validateDelivery(bundle,manifestSha){
  12. const {files,manifest}=readDelivery(bundle,manifestSha);
  13. const release=JSON.parse(files['RELEASE.json']),binding=JSON.parse(files['credential-binding.json']);
  14. need(release.schema==='ksf.enterprise-workshop-authorization.v1'&&release.companyObjectId===COMPANY
  15. &&release.appId===APP&&release.agentId===AGENT&&release.host===HOST,'DELIVERY_IDENTITY_MISMATCH');
  16. need(/^[A-Za-z0-9_-]{1,80}$/.test(release.credentialId||'')
  17. &&release.installDirectory==='/opt/data/ksf-enterprise-auth-'+release.credentialId
  18. &&release.skillDirectory===SKILL&&release.wrapperSha256===WRAPPER_SHA,'INVALID_INSTALL_TARGET');
  19. need(JSON.stringify(release.scopes)===JSON.stringify(SCOPES)&&JSON.stringify(release.projectIds)==='["*"]'
  20. &&release.formalReportPublicationGranted===false&&release.credentialPlaintextIncluded===false
  21. &&release.privateKeyIncluded===false,'DELIVERY_PERMISSION_MISMATCH');
  22. need(binding.version==='ksf.encrypted-entry.v1'&&binding.purpose===PURPOSE&&binding.host===HOST
  23. &&/^\/opt\/data\/ksf-key-receive-20261031-[A-Za-z0-9]+$/.test(binding.keyDirectory)
  24. &&binding.keyDirectory===release.keyDirectory&&binding.publicKeySha256===release.publicKeySha256,'INVALID_BINDING');
  25. need(binding.envelopeFile===release.installDirectory+'/credential-envelope.json'
  26. &&binding.decryptModule===release.installDirectory+'/decrypt-envelope.mjs'
  27. &&binding.envelopeSha256===sha(files['credential-envelope.json'])
  28. &&binding.decryptModuleSha256===DECRYPTOR_SHA&&sha(files['decrypt-envelope.mjs'])===DECRYPTOR_SHA,'BINDING_HASH_MISMATCH');
  29. const envelope=JSON.parse(files['credential-envelope.json']);
  30. need(envelope.header?.recipientHost===HOST&&envelope.header?.recipientDirectory===binding.keyDirectory
  31. &&envelope.header?.publicKeySha256===binding.publicKeySha256&&envelope.header?.expiresAt===release.expiresAt,'ENVELOPE_BINDING_MISMATCH');
  32. return{files,manifest,release,binding,envelope};
  33. }
  34. export async function install({bundle,manifestSha,apply=false},{dataDir='/opt/data',hostname=os.hostname(),
  35. platform=process.platform,spawn=spawnSync,controls=runReadControls}={}){
  36. need(platform==='linux','LINUX_REQUIRED');need(hostname===HOST,'WRONG_CONTAINER');
  37. dataDir=plain(dataDir);const local=target=>path.join(dataDir,...target.slice('/opt/data/'.length).split('/'));
  38. const current=JSON.parse(readPrivate(path.join(dataDir,'.fmode/config/enterprise/current.json')));
  39. need(current.appId===APP&&current.companyObjectId===COMPANY&&current.provisionStatus==='success','ENTERPRISE_MISMATCH');
  40. const {files,manifest,release,binding,envelope}=validateDelivery(bundle,manifestSha);
  41. const skill=local(SKILL),entry=path.join(skill,'scripts/ksf-secure.mjs');
  42. need(sha(fs.readFileSync(entry))===WRAPPER_SHA,'WRAPPER_CHANGED');
  43. need(fs.readFileSync(path.join(skill,'SKILL.md'),'utf8').includes('version: r13'),'SKILL_VERSION_MISMATCH');
  44. const keyDirectory=local(binding.keyDirectory);privateDirectory(keyDirectory);
  45. const privateBytes=readPrivate(path.join(keyDirectory,'recipient-private.pem'));
  46. let key;try{key=crypto.createPrivateKey(privateBytes);}finally{privateBytes.fill(0);}
  47. const payload=decryptEnvelope(envelope,key,{host:HOST,directory:binding.keyDirectory,publicKeySha256:binding.publicKeySha256});
  48. try{
  49. need(payload.credentialId===release.credentialId&&payload.expiresAt===release.expiresAt,'CREDENTIAL_IDENTITY_MISMATCH');
  50. const bindingTarget=path.join(skill,'credential-binding.json'),destination=local(release.installDirectory);
  51. const alreadyBound=fs.existsSync(bindingTarget);
  52. if(alreadyBound)need(readPrivate(bindingTarget).equals(files['credential-binding.json']),'EXISTING_BINDING_CHANGED');
  53. if(fs.existsSync(destination)){
  54. privateDirectory(destination);
  55. need(fs.readdirSync(destination).every(n=>[...DELIVERY_FILES,'FILES.sha256'].includes(n)),'UNKNOWN_INSTALL_FILES');
  56. for(const name of fs.readdirSync(destination))need(readPrivate(path.join(destination,name)).equals(
  57. name==='FILES.sha256'?manifest:files[name]),'EXISTING_AUTH_FILE_CHANGED');
  58. }
  59. const base={ok:true,planOnly:!apply,credentialId:release.credentialId,host:HOST,
  60. scopes:release.scopes,alreadyBound,bundleManifestSha256:manifestSha,
  61. privateKeyExported:false,credentialPlaintextWrittenToDisk:false,protectedRuntimeFilesModified:0,businessRecordWrites:0};
  62. if(!apply)return{...base,bindingInstalled:alreadyBound};
  63. fs.mkdirSync(destination,{recursive:true,mode:0o700});privateDirectory(destination);
  64. for(const[name,body]of [...Object.entries(files),['FILES.sha256',manifest]]){
  65. const target=path.join(destination,name);if(!fs.existsSync(target))writeNew(target,body);
  66. }
  67. if(!alreadyBound)writeNew(bindingTarget,files['credential-binding.json']);
  68. // Exercise the frozen production entry; do not replace it with a direct API success claim.
  69. // Keep verification input outside the sealed authorization directory.
  70. const input=path.join(dataDir,'ksf-enterprise-auth-checks',crypto.randomUUID()+'.json');
  71. writeNew(input,Buffer.from('{}\n'));
  72. const child=spawn(process.execPath,[entry,'workshop-contract','--input',input,'--request-id','enterprise-auth-'+crypto.randomUUID()],
  73. {encoding:'utf8',timeout:120000,maxBuffer:4*1024*1024,windowsHide:true});
  74. need(!String(child.stdout).includes(payload.env.KSF_SERVICE_TOKEN)
  75. &&!String(child.stderr).includes(payload.env.KSF_SERVICE_TOKEN),'OUTPUT_CONTAINS_SECRET');
  76. need(!child.error&&child.status===0,'FROZEN_ENTRY_CONTRACT_FAILED');
  77. const result=JSON.parse(child.stdout);
  78. need(result.ok===true&&result.wrapperSha256===WRAPPER_SHA&&result.credentialLoadedForChild===true
  79. &&result.credentialWrittenToDisk===false&&result.entryResult?.readOnly===true
  80. &&result.entryResult?.result?.workshop?.version==='ksf.workshop.v1','FROZEN_ENTRY_CONTRACT_MISMATCH');
  81. const authControls=await controls(payload.env,'enterprise-auth-'+crypto.randomUUID());
  82. need(sha(fs.readFileSync(entry))===WRAPPER_SHA,'WRAPPER_CHANGED');
  83. return{...base,bindingInstalled:true,workshopContractVerified:true,authorizationControls:authControls,
  84. researchEnvironmentActivated:false,studentContextVerified:false,saveReadbackVerified:false};
  85. }finally{delete payload.env.KSF_SERVICE_TOKEN;}
  86. }
  87. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  88. try{
  89. const a=argsOf(process.argv.slice(2),['--bundle','--sha256','--mode']);
  90. need(a['--bundle']&&a['--sha256']&&['plan','apply'].includes(a['--mode']),'USAGE');
  91. console.log(JSON.stringify(await install({bundle:a['--bundle'],manifestSha:a['--sha256'],apply:a['--mode']==='apply'}),null,2));
  92. }catch(error){console.error(JSON.stringify(errorReceipt(error)));process.exitCode=1;}
  93. }