issue.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. import {sealCredential} from '../lib/workshop-credential-envelope.mjs';
  6. import {COMPANY,APP,AGENT,HOST,SKILL,PURPOSE,WRAPPER_SHA,DECRYPTOR_SHA,SCOPES,sha,encode,need,plain,readPrivate,readDelivery,privateDirectory,writeNew,recipientInfo,argsOf,errorReceipt} from './common.mjs';
  7. const self=fileURLToPath(import.meta.url);
  8. const expiry='9999-12-31T00:00:00.000Z';
  9. const expiredAt=row=>row.expiresAt?.iso||row.expiresAt;
  10. const publicRow=row=>({credentialId:row.objectId,label:row.label,enabled:row.enabled,expiresAt:expiredAt(row),scopes:row.scopes,projectIds:row.projectIds});
  11. function saveJournal(output,value){
  12. const dest=path.join(output,'issuance-state.json'),temp=dest+'.'+crypto.randomUUID();
  13. writeNew(temp,encode(value));fs.renameSync(temp,dest);
  14. }
  15. function checkRow(row,expected){
  16. need(row?.objectId&&row.label===expected.label&&row.tokenHash===expected.tokenHash
  17. &&JSON.stringify(row.scopes)===JSON.stringify(SCOPES)&&JSON.stringify(row.projectIds)==='["*"]'
  18. &&expiredAt(row)===expiry,'ISSUED_CREDENTIAL_READBACK_MISMATCH');
  19. }
  20. export async function issue({recipientFile,output,apply=false},{request,invoke}={}){
  21. recipientFile=plain(recipientFile);output=plain(output);
  22. need(!recipientFile.startsWith(output+path.sep)&&recipientFile!==output,'OUTPUT_OVERLAPS_RECIPIENT');
  23. const recipientBytes=fs.readFileSync(recipientFile),recipient=recipientInfo(JSON.parse(recipientBytes));
  24. const label='ksf-enterprise:'+COMPANY+':'+AGENT+':'+recipient.publicKeySha256;
  25. const query=async()=> (await request('GET','/classes/KsfServiceCredential?'+new URLSearchParams({where:JSON.stringify({label}),limit:'2'}))).results;
  26. const rows=await query();need(Array.isArray(rows)&&rows.length<=1,'DUPLICATE_SERVICE_CREDENTIAL');
  27. const journalPath=path.join(output,'issuance-state.json');
  28. if(rows.length){
  29. need(fs.existsSync(journalPath),'EXISTING_SERVICE_CREDENTIAL_REQUIRES_RECONCILIATION');
  30. privateDirectory(output);const state=JSON.parse(readPrivate(journalPath));
  31. need(state.phase==='complete'&&state.recipientSha256===sha(recipientBytes),'INCOMPLETE_ISSUANCE_REQUIRES_RECONCILIATION');
  32. checkRow(rows[0],state);need(rows[0].enabled===true,'ISSUED_CREDENTIAL_DISABLED');
  33. const bundle=path.join(output,'delivery');
  34. readDelivery(bundle,state.bundleManifestSha256);
  35. return{ok:true,alreadyIssued:true,...publicRow(rows[0]),deliveryDirectory:bundle,
  36. bundleManifestSha256:state.bundleManifestSha256,
  37. credentialPlaintextWrittenToDisk:false,authorizationRecordWrites:0,businessRecordWrites:0};
  38. }
  39. const plan={schema:'ksf.enterprise-workshop-issuance-plan.v1',companyObjectId:COMPANY,appId:APP,
  40. host:HOST,agentId:AGENT,label,recipientSha256:sha(recipientBytes),publicKeySha256:recipient.publicKeySha256,
  41. scopes:SCOPES,projectIds:['*'],expiresAt:expiry,formalReportPublicationGranted:false};
  42. if(!apply)return{ok:true,planOnly:true,...plan,authorizationRecordWrites:0,businessRecordWrites:0};
  43. need(!fs.existsSync(output),'NEW_PRIVATE_OUTPUT_REQUIRED');
  44. fs.mkdirSync(output,{recursive:true,mode:0o700});
  45. privateDirectory(output);
  46. const bytes=crypto.randomBytes(32),token=bytes.toString('base64url');bytes.fill(0);
  47. const expected={label,tokenHash:sha(token)},state={...plan,...expected,phase:'prepared'};
  48. saveJournal(output,state);
  49. const env={KSF_FUNCTIONS_URL:'https://kangshifu.fmode.cn/api/functions',KSF_PARSE_APP_ID:APP,KSF_SERVICE_TOKEN:token};
  50. try{
  51. const created=await request('POST','/classes/KsfServiceCredential',{
  52. ...expected,scopes:SCOPES,projectIds:['*'],enabled:false,expiresAt:{__type:'Date',iso:expiry},ACL:{}});
  53. need(/^[A-Za-z0-9_-]{1,80}$/.test(created.objectId||''),'CREDENTIAL_CREATE_RESULT_UNCERTAIN');
  54. state.credentialId=created.objectId;state.phase='disabled-record-created';saveJournal(output,state);
  55. let row=await request('GET','/classes/KsfServiceCredential/'+created.objectId);checkRow(row,expected);
  56. need(row.enabled===false,'CREDENTIAL_ENABLED_TOO_EARLY');
  57. const envelope=sealCredential({publicKey:recipient.publicKeyPem,host:HOST,directory:recipient.keyDirectory,
  58. credentialId:created.objectId,expiresAt:expiry,env});
  59. const envelopeBytes=encode(envelope);
  60. const decryptor=fs.readFileSync(path.join(path.dirname(self),'decrypt-envelope.mjs'));
  61. need(sha(decryptor)===DECRYPTOR_SHA,'DECRYPTOR_CHANGED');
  62. const installDirectory='/opt/data/ksf-enterprise-auth-'+created.objectId;
  63. const binding={version:'ksf.encrypted-entry.v1',purpose:PURPOSE,host:HOST,
  64. envelopeFile:installDirectory+'/credential-envelope.json',envelopeSha256:sha(envelopeBytes),
  65. keyDirectory:recipient.keyDirectory,publicKeySha256:recipient.publicKeySha256,
  66. decryptModule:installDirectory+'/decrypt-envelope.mjs',decryptModuleSha256:sha(decryptor)};
  67. const release={schema:'ksf.enterprise-workshop-authorization.v1',companyObjectId:COMPANY,appId:APP,
  68. agentId:AGENT,host:HOST,credentialId:created.objectId,scopes:SCOPES,projectIds:['*'],expiresAt:expiry,
  69. installDirectory,skillDirectory:SKILL,wrapperSha256:WRAPPER_SHA,
  70. recipientSha256:sha(recipientBytes),keyDirectory:recipient.keyDirectory,publicKeySha256:recipient.publicKeySha256,
  71. credentialPlaintextIncluded:false,privateKeyIncluded:false,formalReportPublicationGranted:false};
  72. const files={'credential-envelope.json':envelopeBytes,'credential-binding.json':encode(binding),
  73. 'decrypt-envelope.mjs':decryptor,'RELEASE.json':encode(release)};
  74. const bundle=path.join(output,'delivery');
  75. for(const[n,body]of Object.entries(files)){
  76. need(!body.includes(Buffer.from(token))&&!body.includes(Buffer.from('-----BEGIN PRIVATE KEY-----')),'SECRET_IN_DELIVERY');
  77. writeNew(path.join(bundle,n),body);
  78. }
  79. writeNew(path.join(bundle,'FILES.sha256'),Buffer.from(Object.entries(files).map(([n,b])=>sha(b)+' '+n+'\n').join('')));
  80. state.bundleManifestSha256=sha(fs.readFileSync(path.join(bundle,'FILES.sha256')));
  81. readDelivery(bundle,state.bundleManifestSha256);
  82. state.phase='encrypted-delivery-prepared';saveJournal(output,state);
  83. // The recoverable encrypted delivery is on disk before activating the new credential.
  84. await request('PUT','/classes/KsfServiceCredential/'+created.objectId,{enabled:true});
  85. state.phase='enabled-awaiting-controls';saveJournal(output,state);
  86. row=await request('GET','/classes/KsfServiceCredential/'+created.objectId);checkRow(row,expected);
  87. need(row.enabled===true,'CREDENTIAL_ENABLE_READBACK_FAILED');
  88. const headers={'Content-Type':'application/json','X-Parse-Application-Id':APP,'X-Ksf-Service-Token':token};
  89. const contract=await invoke('ksfGetIngestContract',{},headers);
  90. need(contract.http===200&&contract.body.result?.ok===true
  91. &&contract.body.result.data?.workshop?.version==='ksf.workshop.v1','ISSUED_CONTRACT_FAILED');
  92. const probe=await invoke('ksfGetRecord',{className:'KfSource',objectId:'auth-check-'+crypto.randomUUID()},headers);
  93. need(probe.http===400&&probe.body.result?.errors?.some(e=>e.code==='REFERENCE_NOT_FOUND'),'ISSUED_AUTH_CONTROL_FAILED');
  94. for(const controlToken of [null,'invalid-control-'+crypto.randomBytes(24).toString('base64url')]){
  95. const controlHeaders={'Content-Type':'application/json','X-Parse-Application-Id':APP};
  96. if(controlToken)controlHeaders['X-Ksf-Service-Token']=controlToken;
  97. const control=await invoke('ksfGetRecord',{className:'KfSource',objectId:'auth-check-'+crypto.randomUUID()},controlHeaders);
  98. need(control.http===401&&control.body.result?.errors?.some(e=>e.code==='AUTH_REQUIRED'),'ISSUED_AUTH_CONTROL_FAILED');
  99. }
  100. state.phase='complete';saveJournal(output,state);
  101. const receipt={ok:true,alreadyIssued:false,...publicRow(row),deliveryDirectory:bundle,
  102. bundleManifestSha256:state.bundleManifestSha256,
  103. contractVerified:true,readAuthorizationVerified:true,authorizationRecordWrites:2,businessRecordWrites:0,
  104. credentialPlaintextWrittenToDisk:false,targetBindingInstalled:false};
  105. writeNew(path.join(output,'issuance-receipt.json'),encode(receipt));
  106. return receipt;
  107. }catch(error){state.lastErrorCode=/^[A-Z][A-Z0-9_]+$/.test(error.code||'')?error.code:'ISSUANCE_STOPPED';
  108. saveJournal(output,state);throw error;
  109. }finally{delete env.KSF_SERVICE_TOKEN;}
  110. }
  111. if(process.argv[1]&&path.resolve(process.argv[1])===self){
  112. try{
  113. const a=argsOf(process.argv.slice(2),['--recipient','--out','--mode']);
  114. need(a['--recipient']&&a['--out']&&['plan','apply'].includes(a['--mode']),'USAGE');
  115. const client=await import('../../server/scripts/platform-client.mjs');
  116. console.log(JSON.stringify(await issue({recipientFile:a['--recipient'],output:a['--out'],apply:a['--mode']==='apply'},client),null,2));
  117. }catch(error){console.error(JSON.stringify(errorReceipt(error)));process.exitCode=1;}
  118. }