#include "ble_cloud.h" #include "call_client.h" #include #include #include #include #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=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;ivaluedouble>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