import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import {fileURLToPath} from 'node:url'; import {sealCredential} from '../lib/workshop-credential-envelope.mjs'; 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'; const self=fileURLToPath(import.meta.url); const expiry='9999-12-31T00:00:00.000Z'; const expiredAt=row=>row.expiresAt?.iso||row.expiresAt; const publicRow=row=>({credentialId:row.objectId,label:row.label,enabled:row.enabled,expiresAt:expiredAt(row),scopes:row.scopes,projectIds:row.projectIds}); function saveJournal(output,value){ const dest=path.join(output,'issuance-state.json'),temp=dest+'.'+crypto.randomUUID(); writeNew(temp,encode(value));fs.renameSync(temp,dest); } function checkRow(row,expected){ need(row?.objectId&&row.label===expected.label&&row.tokenHash===expected.tokenHash &&JSON.stringify(row.scopes)===JSON.stringify(SCOPES)&&JSON.stringify(row.projectIds)==='["*"]' &&expiredAt(row)===expiry,'ISSUED_CREDENTIAL_READBACK_MISMATCH'); } export async function issue({recipientFile,output,apply=false},{request,invoke}={}){ recipientFile=plain(recipientFile);output=plain(output); need(!recipientFile.startsWith(output+path.sep)&&recipientFile!==output,'OUTPUT_OVERLAPS_RECIPIENT'); const recipientBytes=fs.readFileSync(recipientFile),recipient=recipientInfo(JSON.parse(recipientBytes)); const label='ksf-enterprise:'+COMPANY+':'+AGENT+':'+recipient.publicKeySha256; const query=async()=> (await request('GET','/classes/KsfServiceCredential?'+new URLSearchParams({where:JSON.stringify({label}),limit:'2'}))).results; const rows=await query();need(Array.isArray(rows)&&rows.length<=1,'DUPLICATE_SERVICE_CREDENTIAL'); const journalPath=path.join(output,'issuance-state.json'); if(rows.length){ need(fs.existsSync(journalPath),'EXISTING_SERVICE_CREDENTIAL_REQUIRES_RECONCILIATION'); privateDirectory(output);const state=JSON.parse(readPrivate(journalPath)); need(state.phase==='complete'&&state.recipientSha256===sha(recipientBytes),'INCOMPLETE_ISSUANCE_REQUIRES_RECONCILIATION'); checkRow(rows[0],state);need(rows[0].enabled===true,'ISSUED_CREDENTIAL_DISABLED'); const bundle=path.join(output,'delivery'); readDelivery(bundle,state.bundleManifestSha256); return{ok:true,alreadyIssued:true,...publicRow(rows[0]),deliveryDirectory:bundle, bundleManifestSha256:state.bundleManifestSha256, credentialPlaintextWrittenToDisk:false,authorizationRecordWrites:0,businessRecordWrites:0}; } const plan={schema:'ksf.enterprise-workshop-issuance-plan.v1',companyObjectId:COMPANY,appId:APP, host:HOST,agentId:AGENT,label,recipientSha256:sha(recipientBytes),publicKeySha256:recipient.publicKeySha256, scopes:SCOPES,projectIds:['*'],expiresAt:expiry,formalReportPublicationGranted:false}; if(!apply)return{ok:true,planOnly:true,...plan,authorizationRecordWrites:0,businessRecordWrites:0}; need(!fs.existsSync(output),'NEW_PRIVATE_OUTPUT_REQUIRED'); fs.mkdirSync(output,{recursive:true,mode:0o700}); privateDirectory(output); const bytes=crypto.randomBytes(32),token=bytes.toString('base64url');bytes.fill(0); const expected={label,tokenHash:sha(token)},state={...plan,...expected,phase:'prepared'}; saveJournal(output,state); const env={KSF_FUNCTIONS_URL:'https://kangshifu.fmode.cn/api/functions',KSF_PARSE_APP_ID:APP,KSF_SERVICE_TOKEN:token}; try{ const created=await request('POST','/classes/KsfServiceCredential',{ ...expected,scopes:SCOPES,projectIds:['*'],enabled:false,expiresAt:{__type:'Date',iso:expiry},ACL:{}}); need(/^[A-Za-z0-9_-]{1,80}$/.test(created.objectId||''),'CREDENTIAL_CREATE_RESULT_UNCERTAIN'); state.credentialId=created.objectId;state.phase='disabled-record-created';saveJournal(output,state); let row=await request('GET','/classes/KsfServiceCredential/'+created.objectId);checkRow(row,expected); need(row.enabled===false,'CREDENTIAL_ENABLED_TOO_EARLY'); const envelope=sealCredential({publicKey:recipient.publicKeyPem,host:HOST,directory:recipient.keyDirectory, credentialId:created.objectId,expiresAt:expiry,env}); const envelopeBytes=encode(envelope); const decryptor=fs.readFileSync(path.join(path.dirname(self),'decrypt-envelope.mjs')); need(sha(decryptor)===DECRYPTOR_SHA,'DECRYPTOR_CHANGED'); const installDirectory='/opt/data/ksf-enterprise-auth-'+created.objectId; const binding={version:'ksf.encrypted-entry.v1',purpose:PURPOSE,host:HOST, envelopeFile:installDirectory+'/credential-envelope.json',envelopeSha256:sha(envelopeBytes), keyDirectory:recipient.keyDirectory,publicKeySha256:recipient.publicKeySha256, decryptModule:installDirectory+'/decrypt-envelope.mjs',decryptModuleSha256:sha(decryptor)}; const release={schema:'ksf.enterprise-workshop-authorization.v1',companyObjectId:COMPANY,appId:APP, agentId:AGENT,host:HOST,credentialId:created.objectId,scopes:SCOPES,projectIds:['*'],expiresAt:expiry, installDirectory,skillDirectory:SKILL,wrapperSha256:WRAPPER_SHA, recipientSha256:sha(recipientBytes),keyDirectory:recipient.keyDirectory,publicKeySha256:recipient.publicKeySha256, credentialPlaintextIncluded:false,privateKeyIncluded:false,formalReportPublicationGranted:false}; const files={'credential-envelope.json':envelopeBytes,'credential-binding.json':encode(binding), 'decrypt-envelope.mjs':decryptor,'RELEASE.json':encode(release)}; const bundle=path.join(output,'delivery'); for(const[n,body]of Object.entries(files)){ need(!body.includes(Buffer.from(token))&&!body.includes(Buffer.from('-----BEGIN PRIVATE KEY-----')),'SECRET_IN_DELIVERY'); writeNew(path.join(bundle,n),body); } writeNew(path.join(bundle,'FILES.sha256'),Buffer.from(Object.entries(files).map(([n,b])=>sha(b)+' '+n+'\n').join(''))); state.bundleManifestSha256=sha(fs.readFileSync(path.join(bundle,'FILES.sha256'))); readDelivery(bundle,state.bundleManifestSha256); state.phase='encrypted-delivery-prepared';saveJournal(output,state); // The recoverable encrypted delivery is on disk before activating the new credential. await request('PUT','/classes/KsfServiceCredential/'+created.objectId,{enabled:true}); state.phase='enabled-awaiting-controls';saveJournal(output,state); row=await request('GET','/classes/KsfServiceCredential/'+created.objectId);checkRow(row,expected); need(row.enabled===true,'CREDENTIAL_ENABLE_READBACK_FAILED'); const headers={'Content-Type':'application/json','X-Parse-Application-Id':APP,'X-Ksf-Service-Token':token}; const contract=await invoke('ksfGetIngestContract',{},headers); need(contract.http===200&&contract.body.result?.ok===true &&contract.body.result.data?.workshop?.version==='ksf.workshop.v1','ISSUED_CONTRACT_FAILED'); const probe=await invoke('ksfGetRecord',{className:'KfSource',objectId:'auth-check-'+crypto.randomUUID()},headers); need(probe.http===400&&probe.body.result?.errors?.some(e=>e.code==='REFERENCE_NOT_FOUND'),'ISSUED_AUTH_CONTROL_FAILED'); for(const controlToken of [null,'invalid-control-'+crypto.randomBytes(24).toString('base64url')]){ const controlHeaders={'Content-Type':'application/json','X-Parse-Application-Id':APP}; if(controlToken)controlHeaders['X-Ksf-Service-Token']=controlToken; const control=await invoke('ksfGetRecord',{className:'KfSource',objectId:'auth-check-'+crypto.randomUUID()},controlHeaders); need(control.http===401&&control.body.result?.errors?.some(e=>e.code==='AUTH_REQUIRED'),'ISSUED_AUTH_CONTROL_FAILED'); } state.phase='complete';saveJournal(output,state); const receipt={ok:true,alreadyIssued:false,...publicRow(row),deliveryDirectory:bundle, bundleManifestSha256:state.bundleManifestSha256, contractVerified:true,readAuthorizationVerified:true,authorizationRecordWrites:2,businessRecordWrites:0, credentialPlaintextWrittenToDisk:false,targetBindingInstalled:false}; writeNew(path.join(output,'issuance-receipt.json'),encode(receipt)); return receipt; }catch(error){state.lastErrorCode=/^[A-Z][A-Z0-9_]+$/.test(error.code||'')?error.code:'ISSUANCE_STOPPED'; saveJournal(output,state);throw error; }finally{delete env.KSF_SERVICE_TOKEN;} } if(process.argv[1]&&path.resolve(process.argv[1])===self){ try{ const a=argsOf(process.argv.slice(2),['--recipient','--out','--mode']); need(a['--recipient']&&a['--out']&&['plan','apply'].includes(a['--mode']),'USAGE'); const client=await import('../../server/scripts/platform-client.mjs'); console.log(JSON.stringify(await issue({recipientFile:a['--recipient'],output:a['--out'],apply:a['--mode']==='apply'},client),null,2)); }catch(error){console.error(JSON.stringify(errorReceipt(error)));process.exitCode=1;} }