Files

38 lines
4.7 KiB
JavaScript
Raw Permalink Normal View History

// Node.js 18+. No packages. Default: offline vectors. --probe FILE: only this
// handoff device's time/register/unbound checks; never binds or registers Volcano.
const crypto=require('node:crypto'),fs=require('node:fs'),assert=require('node:assert/strict');
const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
const hmac=(key,text)=>crypto.createHmac('sha256',key).update(text,'utf8').digest('hex');
function sign(key,{method='POST',path,deviceId,keyVersion=1,timestamp,nonce,requestId,raw}) {
const bodySha256=sha(Buffer.from(raw,'utf8'));
const canonical=['DEVREQ1',method.toUpperCase(),path,deviceId,String(keyVersion),String(timestamp),nonce,requestId,bodySha256].join('\n');
const signature=hmac(key,canonical);
return {bodySha256,canonical,signature,headers:{'Content-Type':'application/json','X-Device-ID':deviceId,'X-Key-Version':String(keyVersion),'X-Timestamp-Ms':String(timestamp),'X-Nonce':nonce,'X-Request-ID':requestId,'Authorization':'Device-HMAC-SHA256 '+signature}};
}
function frame(raw){const bytes=Buffer.from(raw,'utf8');if(bytes.length<1||bytes.length>2048)throw Error('invalid length');const head=Buffer.alloc(2);head.writeUInt16LE(bytes.length);return Buffer.concat([head,bytes]);}
function vectors(){
const key=Buffer.from(Array.from({length:32},(_,i)=>i));
const input={path:'/api/v1/device/ble-state',deviceId:'KP01-020000260914',timestamp:1789372800000,nonce:Buffer.from(Array.from({length:16},(_,i)=>i)).toString('base64url'),requestId:'12345678-1234-4234-8234-123456789abc',raw:'{}'};
const calculated=sign(key,input);const bleRaw='{"cmd":"get_info"}';
assert.equal(frame(bleRaw).toString('hex'),'12007b22636d64223a226765745f696e666f227d');
return {warning:'OFFLINE TEST KEY ONLY. NOT THE LIVE DEVICE KEY. Fixed timestamps/nonces must not be used in live requests.',testKeyHex:key.toString('hex'),input,...calculated,ble:{raw:bleRaw,utf8Length:Buffer.byteLength(bleRaw),frameHex:frame(bleRaw).toString('hex')}};
}
async function probe(file){
const init=JSON.parse(fs.readFileSync(file,'utf8').replace(/^\uFEFF/,''));
assert.equal(init.factory_id,'KP01-020000260914','probe restricted to handoff device');assert.equal(init.origin,'https://82.157.201.114');
const key=Buffer.from(init.device_key_hex,'hex');assert.equal(key.length,32);
const boot=crypto.randomUUID(),nonce=crypto.randomBytes(16).toString('base64url');
let before=performance.now();
const response=await fetch(init.origin.replace('https:','http:')+'/api/v1/device/time-challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:init.factory_id,key_version:init.key_version,boot_id:boot,client_nonce:nonce}),redirect:'error',signal:AbortSignal.timeout(15000)});
assert.equal(response.status,200);const clock=await response.json();assert.equal(clock.mac_hex,hmac(key,'TIME1\n'+clock.payload_b64));
const payload=JSON.parse(Buffer.from(clock.payload_b64,'base64url'));assert.equal(payload.device_id,init.factory_id);assert.equal(payload.boot_id,boot);assert.equal(payload.client_nonce,nonce);assert.ok(performance.now()-before<15000);
const offset=payload.server_time_ms-Date.now();
async function post(path,body){const raw=JSON.stringify(body);const signed=sign(key,{path,deviceId:init.factory_id,keyVersion:init.key_version,timestamp:Date.now()+offset,nonce:crypto.randomBytes(16).toString('base64url'),requestId:crypto.randomUUID(),raw});const r=await fetch(init.origin+path,{method:'POST',headers:signed.headers,body:raw,redirect:'error',signal:AbortSignal.timeout(20000)});return {status:r.status,data:await r.json()};}
const registered=await post('/api/v1/device/register',{challenge_id:payload.challenge_id,boot_id:boot,hardware_model:init.model,hardware_revision:init.hardware_revision,firmware_version:'handoff-probe-1',sdk_version:'2.0.0'});assert.equal(registered.status,200);assert.equal(registered.data.data.state.binding_state,'UNBOUND');
const state=await post('/api/v1/device/ble-state',{});assert.equal(state.status,200);assert.equal(state.data.data.bound,false);
const chat=await post('/api/v1/device/chat/start',{device_id:init.factory_id,session_id:crypto.randomUUID(),boot_id:boot});assert.equal(chat.status,409);assert.equal(chat.data.code,'DEVICE_UNBOUND');
console.log(JSON.stringify({device_id:init.factory_id,time_mac_verified:true,register_status:registered.status,binding_state:registered.data.data.state.binding_state,ble_state:state.data.data.bound,chat_status:chat.status,chat_error:chat.data.code,provider_registration_performed:false},null,2));
}
module.exports={sign,frame,vectors};
if(require.main===module){if(process.argv[2]==='--probe'){probe(process.argv[3]).catch(()=>{console.error('Probe failed; check network, identity and current device state. No credentials printed.');process.exitCode=1;});}else console.log(JSON.stringify(vectors(),null,2));}