ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
241 lines
14 KiB
C
241 lines
14 KiB
C
#include "ble_cloud.h"
|
|
#include "call_client.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/time.h>
|
|
#include "cJSON.h"
|
|
#include "esp_crt_bundle.h"
|
|
#include "esp_random.h"
|
|
#include "esp_timer.h"
|
|
#include "mbedtls/base64.h"
|
|
#include "mbedtls/md.h"
|
|
#include "mbedtls/sha256.h"
|
|
#include "mbedtls/platform_util.h"
|
|
#include "nvs.h"
|
|
#include "nvs_flash.h"
|
|
|
|
static char factory_id[18], model[33], origin[160];
|
|
static uint8_t device_key[32];
|
|
static uint32_t key_version;
|
|
static bool configured;
|
|
static char voice_boot[37], time_challenge[37];
|
|
const char *ble_cloud_origin(void) { return origin; }
|
|
const char *ble_cloud_boot_id(void) { if (!voice_boot[0]) ble_random_uuid(voice_boot); return voice_boot; }
|
|
const char *ble_cloud_challenge_id(void) { return time_challenge; }
|
|
typedef struct { char data[4097]; size_t len; } response_t;
|
|
|
|
static esp_err_t response_event(esp_http_client_event_t *event)
|
|
{
|
|
response_t *r = event->user_data;
|
|
if (event->event_id == HTTP_EVENT_ON_DATA && event->data_len > 0) {
|
|
if (r->len + event->data_len >= sizeof(r->data)) return ESP_ERR_INVALID_SIZE;
|
|
memcpy(r->data + r->len, event->data, event->data_len);
|
|
r->len += event->data_len;
|
|
r->data[r->len] = 0;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
static const char *string(cJSON *obj, const char *key)
|
|
{
|
|
cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
|
|
return cJSON_IsString(v) ? v->valuestring : "";
|
|
}
|
|
static esp_err_t getstr(nvs_handle_t nvs, const char *key, char *out, size_t len)
|
|
{ return nvs_get_str(nvs, key, out, &len); }
|
|
esp_err_t ble_cloud_init(void)
|
|
{
|
|
if (configured) return ESP_OK;
|
|
esp_err_t err = nvs_flash_init_partition("ble_factory");
|
|
if (err != ESP_OK) return err; // Never erase factory credentials on an init error.
|
|
nvs_handle_t nvs;
|
|
err = nvs_open_from_partition("ble_factory", "identity", NVS_READONLY, &nvs);
|
|
if (err != ESP_OK) return err;
|
|
size_t len = sizeof(device_key);
|
|
err = getstr(nvs,"factory_id",factory_id,sizeof(factory_id));
|
|
if (err == ESP_OK) err = getstr(nvs,"model",model,sizeof(model));
|
|
if (err == ESP_OK) err = getstr(nvs,"origin",origin,sizeof(origin));
|
|
if (err == ESP_OK) err = nvs_get_blob(nvs,"device_key",device_key,&len);
|
|
if (err == ESP_OK && len != 32) err = ESP_ERR_INVALID_SIZE;
|
|
if (err == ESP_OK) err = nvs_get_u32(nvs,"key_version",&key_version);
|
|
nvs_close(nvs);
|
|
if (err != ESP_OK || strlen(factory_id) != 17 || strncmp(factory_id,"KP01-",5) ||
|
|
strspn(factory_id+5,"0123456789ABCDEF") != 12 || !model[0] || !key_version ||
|
|
strncmp(origin,"https://",8) || strchr(origin+8,'/') || strchr(origin,'@') ||
|
|
strpbrk(origin,"\r\n?#")) { mbedtls_platform_zeroize(device_key,32); return ESP_ERR_INVALID_ARG; }
|
|
// Move this project's old server address without rewriting factory keys.
|
|
if (!strcmp(origin,"https://121.41.46.238"))
|
|
snprintf(origin,sizeof(origin),"https://82.157.201.114");
|
|
configured = true;
|
|
return ESP_OK;
|
|
}
|
|
bool ble_cloud_configured(void) { return configured; }
|
|
const char *ble_factory_id(void) { return factory_id; }
|
|
const char *ble_factory_model(void) { return model; }
|
|
esp_err_t ble_factory_security(uint8_t salt[16], uint8_t verifier[384])
|
|
{
|
|
nvs_handle_t nvs;
|
|
esp_err_t err = nvs_open_from_partition("ble_factory","identity",NVS_READONLY,&nvs);
|
|
if (err != ESP_OK) return err;
|
|
size_t len = 16;
|
|
err = nvs_get_blob(nvs,"salt",salt,&len);
|
|
if (err == ESP_OK && len != 16) err = ESP_ERR_INVALID_SIZE;
|
|
len = 384;
|
|
if (err == ESP_OK) err = nvs_get_blob(nvs,"verifier",verifier,&len);
|
|
if (err == ESP_OK && len != 384) err = ESP_ERR_INVALID_SIZE;
|
|
nvs_close(nvs);
|
|
return err;
|
|
}
|
|
void ble_random_uuid(char out[37])
|
|
{
|
|
uint8_t b[16]; esp_fill_random(b,16); b[6]=(b[6]&15)|64; b[8]=(b[8]&63)|128;
|
|
snprintf(out,37,"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",b[0],b[1],b[2],b[3],b[4],b[5],b[6],b[7],b[8],b[9],b[10],b[11],b[12],b[13],b[14],b[15]);
|
|
}
|
|
static void hex(const uint8_t *in, size_t n, char *out)
|
|
{ for(size_t i=0;i<n;i++) snprintf(out+i*2,3,"%02x",in[i]); }
|
|
static void mac(const char *input, char out[65])
|
|
{
|
|
uint8_t h[32];
|
|
mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256),device_key,32,(const uint8_t*)input,strlen(input),h);
|
|
hex(h,32,out); mbedtls_platform_zeroize(h,32);
|
|
}
|
|
static void nonce(char out[23])
|
|
{
|
|
uint8_t b[16], encoded[25]; size_t len;
|
|
esp_fill_random(b,16); mbedtls_base64_encode(encoded,sizeof(encoded),&len,b,16);
|
|
for(size_t i=0;i<22;i++) out[i]=encoded[i]=='+'?'-':encoded[i]=='/'?'_':encoded[i];
|
|
out[22]=0;
|
|
}
|
|
esp_err_t ble_cloud_bootstrap(const char *client_nonce, char **response, char password[65])
|
|
{
|
|
if(!configured||!client_nonce||strlen(client_nonce)!=22||strspn(client_nonce,"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")!=22)return ESP_ERR_INVALID_ARG;
|
|
char server_nonce[23],transcript[256],proof[65];nonce(server_nonce);
|
|
snprintf(transcript,sizeof(transcript),"BLE-PROOF1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
|
|
mac(transcript,proof);
|
|
snprintf(transcript,sizeof(transcript),"BLE-PASS1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
|
|
mac(transcript,password);
|
|
cJSON *out=cJSON_CreateObject();if(!out)return ESP_ERR_NO_MEM;
|
|
cJSON_AddStringToObject(out,"device_id",factory_id);cJSON_AddStringToObject(out,"model",model);
|
|
cJSON_AddNumberToObject(out,"key_version",key_version);
|
|
cJSON_AddStringToObject(out,"client_nonce",client_nonce);cJSON_AddStringToObject(out,"server_nonce",server_nonce);cJSON_AddStringToObject(out,"proof",proof);
|
|
*response=cJSON_PrintUnformatted(out);cJSON_Delete(out);
|
|
mbedtls_platform_zeroize(transcript,sizeof(transcript));
|
|
return *response?ESP_OK:ESP_ERR_NO_MEM;
|
|
}
|
|
esp_err_t ble_cloud_headers(esp_http_client_handle_t client,const char *method,const char *path,const char *body,const char *request_id)
|
|
{
|
|
if (!configured) return ESP_ERR_INVALID_STATE;
|
|
char timestamp[24], version[16], random[23], digest[65], signature[65], auth[86], canonical[768];
|
|
struct timeval tv; gettimeofday(&tv,NULL);
|
|
snprintf(timestamp,sizeof(timestamp),"%lld",(long long)tv.tv_sec*1000+tv.tv_usec/1000);
|
|
snprintf(version,sizeof(version),"%lu",(unsigned long)key_version);
|
|
nonce(random); uint8_t hash[32]; mbedtls_sha256((const uint8_t*)body,strlen(body),hash,0); hex(hash,32,digest);
|
|
int n=snprintf(canonical,sizeof(canonical),"DEVREQ1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s",method,path,factory_id,version,timestamp,random,request_id,digest);
|
|
if(n<0 || n>=sizeof(canonical)) return ESP_ERR_INVALID_SIZE;
|
|
mac(canonical,signature); snprintf(auth,sizeof(auth),"Device-HMAC-SHA256 %s",signature);
|
|
esp_err_t err=esp_http_client_set_header(client,"X-Device-ID",factory_id);
|
|
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Key-Version",version);
|
|
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Timestamp-Ms",timestamp);
|
|
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Nonce",random);
|
|
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Request-ID",request_id);
|
|
if(err==ESP_OK) err=esp_http_client_set_header(client,"Authorization",auth);
|
|
return err;
|
|
}
|
|
static esp_err_t post(const char *path,const char *body,const char *request_id,response_t *out,int *status,bool signed_request)
|
|
{
|
|
char url[256];
|
|
// TIME1 is the sole HTTP endpoint: its response is bound to a fresh nonce
|
|
// and authenticated with the factory key. Every credential-bearing call is HTTPS.
|
|
if(!signed_request && !strcmp(path,"/api/v1/device/time-challenges"))snprintf(url,sizeof(url),"http://%s%s",origin+8,path);
|
|
else snprintf(url,sizeof(url),"%s%s",origin,path);
|
|
esp_http_client_config_t cfg={.url=url,.timeout_ms=!strcmp(path,"/api/v1/device/ble-bind")?45000:15000,.crt_bundle_attach=esp_crt_bundle_attach,
|
|
.event_handler=response_event,.user_data=out,.disable_auto_redirect=true,.buffer_size=1024,.buffer_size_tx=1536};
|
|
esp_http_client_handle_t client=esp_http_client_init(&cfg);
|
|
if(!client) return ESP_ERR_NO_MEM;
|
|
esp_http_client_set_method(client,HTTP_METHOD_POST);
|
|
esp_http_client_set_header(client,"Content-Type","application/json");
|
|
esp_http_client_set_post_field(client,body,strlen(body));
|
|
esp_err_t err=signed_request?ble_cloud_headers(client,"POST",path,body,request_id):ESP_OK;
|
|
if(err==ESP_OK) err=esp_http_client_perform(client);
|
|
*status=esp_http_client_get_status_code(client); esp_http_client_cleanup(client);
|
|
return err;
|
|
}
|
|
esp_err_t ble_cloud_sync_time(void)
|
|
{
|
|
const char *boot=ble_cloud_boot_id(); char random[23], body[256]; nonce(random);
|
|
snprintf(body,sizeof(body),"{\"device_id\":\"%s\",\"key_version\":%lu,\"boot_id\":\"%s\",\"client_nonce\":\"%s\"}",factory_id,(unsigned long)key_version,boot,random);
|
|
response_t *r=calloc(1,sizeof(*r)); if(!r) return ESP_ERR_NO_MEM;
|
|
int status=0; int64_t start=esp_timer_get_time();
|
|
esp_err_t err=post("/api/v1/device/time-challenges",body,NULL,r,&status,false);
|
|
if(err!=ESP_OK || status!=200){free(r);return ESP_FAIL;}
|
|
cJSON *root=cJSON_Parse(r->data);
|
|
const char *payload=string(root,"payload_b64"), *signature=string(root,"mac_hex");
|
|
char expected[65], canonical[3600];
|
|
if(strlen(payload)>3400 || strlen(signature)!=64){cJSON_Delete(root);free(r);return ESP_FAIL;}
|
|
snprintf(canonical,sizeof(canonical),"TIME1\n%s",payload); mac(canonical,expected);
|
|
unsigned mismatch=0; for(int i=0;i<64;i++) mismatch|=expected[i]^signature[i];
|
|
uint8_t decoded[2600]; char encoded[3500]; size_t len=strlen(payload),decoded_len=0;
|
|
memcpy(encoded,payload,len);
|
|
for(size_t i=0;i<len;i++){if(encoded[i]=='-')encoded[i]='+';else if(encoded[i]=='_')encoded[i]='/';}
|
|
while(len%4) encoded[len++]='=';
|
|
if(mismatch || mbedtls_base64_decode(decoded,sizeof(decoded)-1,&decoded_len,(uint8_t*)encoded,len)!=0){cJSON_Delete(root);free(r);return ESP_FAIL;}
|
|
decoded[decoded_len]=0; cJSON *p=cJSON_Parse((char*)decoded); cJSON *time=cJSON_GetObjectItemCaseSensitive(p,"server_time_ms");
|
|
bool valid=!strcmp(string(p,"device_id"),factory_id)&&!strcmp(string(p,"boot_id"),boot)&&!strcmp(string(p,"client_nonce"),random)&&cJSON_IsNumber(time)&&time->valuedouble>1700000000000.0&&esp_timer_get_time()-start<15000000;
|
|
if(valid){int64_t ms=(int64_t)time->valuedouble;struct timeval tv={.tv_sec=ms/1000,.tv_usec=(ms%1000)*1000};settimeofday(&tv,NULL);snprintf(time_challenge,sizeof(time_challenge),"%s",string(p,"challenge_id"));}
|
|
cJSON_Delete(p);cJSON_Delete(root);free(r);return valid?ESP_OK:ESP_FAIL;
|
|
}
|
|
esp_err_t ble_cloud_bind(const char *token,const char *request_id)
|
|
{
|
|
if(!configured) return ESP_ERR_INVALID_STATE;
|
|
if(ble_cloud_sync_time()!=ESP_OK) return ESP_FAIL;
|
|
char body[80];snprintf(body,sizeof(body),"{\"bind_token\":\"%s\"}",token);
|
|
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
|
|
int status=0;esp_err_t err=post("/api/v1/device/ble-bind",body,request_id,r,&status,true);
|
|
if(err==ESP_OK){
|
|
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data");
|
|
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&!strcmp(string(data,"status"),"success")) {
|
|
err=call_client_save_bind_voice(data);
|
|
// Incomplete responses/storage failures retain the same pending
|
|
// token for retry; they do not mean the account binding was denied.
|
|
if(err!=ESP_OK)err=ESP_FAIL;
|
|
}
|
|
else if(status>=400&&status<500&&status!=408&&status!=429) err=ESP_ERR_INVALID_RESPONSE;
|
|
else err=ESP_FAIL;
|
|
cJSON *secret=cJSON_GetObjectItemCaseSensitive(data,"device_secret");
|
|
if(cJSON_IsString(secret))mbedtls_platform_zeroize(secret->valuestring,strlen(secret->valuestring));
|
|
cJSON_Delete(root);
|
|
}
|
|
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
|
|
}
|
|
esp_err_t ble_cloud_check_bound(const char *request_id)
|
|
{
|
|
if(!configured)return ESP_ERR_INVALID_STATE;
|
|
if(ble_cloud_sync_time()!=ESP_OK)return ESP_FAIL;
|
|
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
|
|
int status=0;esp_err_t err=post("/api/v1/device/ble-state","{}",request_id,r,&status,true);
|
|
if(err==ESP_OK){
|
|
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data"),*bound=cJSON_GetObjectItemCaseSensitive(data,"bound");
|
|
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&cJSON_IsBool(bound))err=cJSON_IsTrue(bound)?ESP_OK:ESP_ERR_INVALID_RESPONSE;
|
|
else if(status>=400&&status<500&&status!=408&&status!=429)err=ESP_ERR_INVALID_RESPONSE;
|
|
else err=ESP_FAIL;
|
|
cJSON_Delete(root);
|
|
}
|
|
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
|
|
}
|
|
|
|
char *ble_cloud_decode(const char *domain,const char *encoded,const char *signature)
|
|
{
|
|
if(!configured || !domain || !encoded || !signature || strlen(signature)!=64 || strlen(encoded)>32768 ||
|
|
strspn(encoded,"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")!=strlen(encoded))return NULL;
|
|
size_t n=strlen(encoded),d=strlen(domain),out_len=0;
|
|
char *canonical=malloc(n+d+2),*padded=calloc(1,n+5),*out=calloc(1,n+1);
|
|
if(!canonical||!padded||!out){free(canonical);free(padded);free(out);return NULL;}
|
|
snprintf(canonical,n+d+2,"%s\n%s",domain,encoded);
|
|
char expected[65];mac(canonical,expected);unsigned mismatch=0;
|
|
for(int i=0;i<64;i++)mismatch|=expected[i]^signature[i];
|
|
memcpy(padded,encoded,n);for(size_t i=0;i<n;i++){if(padded[i]=='-')padded[i]='+';else if(padded[i]=='_')padded[i]='/';}
|
|
size_t len=n;while(len%4)padded[len++]='=';
|
|
bool ok=!mismatch&&mbedtls_base64_decode((uint8_t*)out,n,&out_len,(uint8_t*)padded,len)==0;
|
|
free(canonical);free(padded);if(!ok){free(out);return NULL;}out[out_len]=0;return out;
|
|
}
|