42 lines
6.7 KiB
JavaScript
42 lines
6.7 KiB
JavaScript
'use strict';
|
|
// Fixed, domain-separated application-layer channels over authenticated HTTPS.
|
|
// The server owns one-use challenge consumption; this cryptographic module is stateless.
|
|
const c=require('node:crypto');
|
|
function createChannel(VERSION, payloadProfile=false){
|
|
const MAX=payloadProfile?65536+1024:3*1024*1024;
|
|
const validatePayload=value=>{record(value);const encoded=bytes(value);try{if(encoded.length>65536)fail();return value;}finally{encoded.fill(0);}};
|
|
const fail=()=>{throw Error('Protected management channel refused');};
|
|
const record=value=>{if(!value||typeof value!=='object'||Array.isArray(value))fail();return value;};
|
|
function exact(value,keys){record(value);if(Object.keys(value).sort().join(',')!==keys.slice().sort().join(','))fail();}
|
|
const encode=value=>value.toString('base64url');
|
|
function decode(value,max=MAX){if(typeof value!=='string'||value.length>Math.ceil(max*4/3)||!value.length||!/^[A-Za-z0-9_-]+$/.test(value))fail();const bytes=Buffer.from(value,'base64url');if(bytes.length>max||encode(bytes)!==value)fail();return bytes;}
|
|
function publicKey(pem,type){if(typeof pem!=='string'||pem.length>20000)fail();const key=c.createPublicKey(pem);if(key.asymmetricKeyType!==type)fail();return key;}
|
|
const bytes=value=>Buffer.from(JSON.stringify(value));
|
|
function signed(payload,edPrivate,pqPrivate){const data=bytes(payload);return {...payload,edSignature:encode(c.sign(null,data,edPrivate)),pqSignature:encode(c.sign(null,data,pqPrivate))};}
|
|
function verified(value,keys,edPublic,pqPublic){exact(value,[...keys,'edSignature','pqSignature']);const payload=Object.fromEntries(keys.map(key=>[key,value[key]]));const data=bytes(payload);if(!c.verify(null,data,publicKey(edPublic,'ed25519'),decode(value.edSignature,64))||!c.verify(null,data,publicKey(pqPublic,'ml-dsa-87'),decode(value.pqSignature,5000)))fail();return payload;}
|
|
function identity(edPrivate,pqPrivate,pqPublic){publicKey(pqPublic,'ml-dsa-87');return signed({version:VERSION,signatureKey:pqPublic},edPrivate,pqPrivate);}
|
|
function verifyIdentity(value,edPublic,pin){record(value);if(value.version!==VERSION||pin&&value.signatureKey!==pin)fail();verified(value,['version','signatureKey'],edPublic,pin||value.signatureKey);return value.signatureKey;}
|
|
function challenge(deviceId,edPrivate,pqPrivate,now=Date.now()){
|
|
const pair=c.generateKeyPairSync('ml-kem-1024');
|
|
const bundle=signed({version:VERSION,deviceId,id:encode(c.randomBytes(32)),expiresAt:now+60000,kemKey:pair.publicKey.export({type:'spki',format:'pem'})},edPrivate,pqPrivate);
|
|
return {bundle,privateKey:pair.privateKey};
|
|
}
|
|
function verifyChallenge(value,deviceId,edPublic,pqPublic,now=Date.now()){
|
|
const result=verified(value,['version','deviceId','id','expiresAt','kemKey'],edPublic,pqPublic);
|
|
if(result.version!==VERSION||result.deviceId!==deviceId||decode(result.id,32).length!==32||!Number.isSafeInteger(result.expiresAt)||result.expiresAt<=now||result.expiresAt>now+65000)fail();
|
|
publicKey(result.kemKey,'ml-kem-1024');return result;
|
|
}
|
|
function derive(shared,bundle,direction){const salt=c.createHash('sha384').update(bytes(bundle)).digest();return Buffer.from(c.hkdfSync('sha384',shared,salt,Buffer.from(VERSION+':'+direction),32));}
|
|
const aad=(bundle,direction)=>bytes({version:VERSION,id:bundle.id,deviceId:bundle.deviceId,direction});
|
|
function seal(key,value,bundle,direction){const input=bytes(value);try{if(input.length>MAX)fail();const iv=c.randomBytes(12),cipher=c.createCipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));return {iv:encode(iv),ciphertext:encode(Buffer.concat([cipher.update(input),cipher.final()])),tag:encode(cipher.getAuthTag())};}finally{input.fill(0);}}
|
|
function open(key,value,bundle,direction){exact(value,['iv','ciphertext','tag']);const iv=decode(value.iv,12),tag=decode(value.tag,16);if(iv.length!==12||tag.length!==16)fail();const cipher=c.createDecipheriv('aes-256-gcm',key,iv);cipher.setAAD(aad(bundle,direction));cipher.setAuthTag(tag);let plaintext,partial,final;try{partial=cipher.update(decode(value.ciphertext));final=cipher.final();plaintext=Buffer.concat([partial,final]);return JSON.parse(plaintext.toString('utf8'));}finally{partial?.fill(0);final?.fill(0);plaintext?.fill(0);}}
|
|
function prepare(bundle,payload){if(payloadProfile){if(bundle.version!==VERSION)fail();validatePayload(payload);}const {sharedKey,ciphertext}=c.encapsulate(publicKey(bundle.kemKey,'ml-kem-1024'));const requestKey=derive(sharedKey,bundle,'request'),responseKey=derive(sharedKey,bundle,'response');sharedKey.fill(0);const requestId=encode(c.randomBytes(32));try{return {request:{version:VERSION,id:bundle.id,deviceId:bundle.deviceId,kemCiphertext:encode(ciphertext),encrypted:seal(requestKey,payloadProfile?{purpose:'evidence',requestId,payload}:{purpose:'policy',requestId},bundle,'request')},responseKey,requestId};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}}
|
|
function receive(request,bundle,privateKey){exact(request,['version','id','deviceId','kemCiphertext','encrypted']);if(request.version!==VERSION||request.id!==bundle.id||request.deviceId!==bundle.deviceId||bundle.expiresAt<=Date.now())fail();const ciphertext=decode(request.kemCiphertext,1568);if(ciphertext.length!==1568)fail();const shared=c.decapsulate(privateKey,ciphertext),requestKey=derive(shared,bundle,'request'),responseKey=derive(shared,bundle,'response');shared.fill(0);try{const body=open(requestKey,request.encrypted,bundle,'request');exact(body,payloadProfile?['purpose','requestId','payload']:['purpose','requestId']);if(body.purpose!==(payloadProfile?'evidence':'policy')||decode(body.requestId,32).length!==32)fail();if(payloadProfile)validatePayload(body.payload);return {responseKey,requestId:body.requestId,...(payloadProfile?{payload:body.payload}:{})};}catch(error){responseKey.fill(0);throw error;}finally{requestKey.fill(0);}}
|
|
function response(policy,bundle,context){return {version:VERSION,id:bundle.id,encrypted:seal(context.responseKey,payloadProfile?{requestId:context.requestId,result:validatePayload(policy)}:{requestId:context.requestId,policy},bundle,'response')};}
|
|
function finish(value,bundle,context){exact(value,['version','id','encrypted']);if(value.version!==VERSION||value.id!==bundle.id)fail();const body=open(context.responseKey,value.encrypted,bundle,'response');exact(body,payloadProfile?['requestId','result']:['requestId','policy']);if(body.requestId!==context.requestId)fail();return payloadProfile?validatePayload(body.result):body.policy;}
|
|
return Object.freeze({VERSION,identity,verifyIdentity,challenge,verifyChallenge,prepare,receive,response,finish});
|
|
}
|
|
const policyChannel=createChannel('safetynet-policy-mlkem1024-mldsa87-v1');
|
|
const evidenceChannel=createChannel('safetynet-evidence-mlkem1024-mldsa87-v1',true);
|
|
module.exports={...policyChannel,evidenceChannel};
|