Jeden złoty certyfikat na całą linię produkcyjną
Spis treści8 ▾
Gdzieś między dziesiątym prototypem a pierwszą partią produkcyjną każdy projekt urządzenia IoT natrafia na to samo pytanie. Na biurku certyfikat trafiał na każdą płytkę najprostszą drogą: ktoś wygenerował go w konsoli, skopiował pliki do projektu i wgrał je razem z firmware. Przy dziesięciu płytkach to działa, przy tysiącu się sypie, bo fabryka musi teraz wykonać osobny krok dla każdej sztuki, mieć bezpieczny sposób na przekazanie unikalnych sekretów i prowadzić ewidencję, który certyfikat trafił na którą płytkę. Większość producentów kontraktowych jest przygotowana na wgrywanie jednego, identycznego obrazu na wszystkie urządzenia. Wstrzyknięcie do każdej sztuki osobnego klucza prywatnego oznacza dodatkowy koszt, czas i bezpieczne przekazanie sekretów, a tego wielu z nich wolałoby uniknąć. Niedawno trafiłem na Reddicie na dokładnie takie pytanie od kogoś, kto korzysta z ThingsBoard, a odpowiedzi były dość mizerne, więc opiszę, jak ja to rozwiązuję.
W takiej sytuacji sięgam po podejście nazywane często „złotym certyfikatem”. Każde urządzenie wyjeżdża z fabryki z tym samym firmware i tym samym wspólnym certyfikatem, a ten certyfikat pozwala na dokładnie jedną rzecz: przy pierwszym połączeniu urządzenie może nim poprosić o własną, docelową tożsamość. AWS IoT Core obsługuje ten schemat wprost pod nazwą fleet provisioning by claim. W dalszej części wpisu pokazuję, jak to u siebie konfiguruję.
Jak przebiega wymiana
Wspólny certyfikat to tak zwany claim certificate, dalej nazywam go po prostu certyfikatem claim. Urządzenie łączy się przy jego użyciu z AWS IoT Core, lokalnie generuje nową parę kluczy i wysyła przez MQTT żądanie podpisania certyfikatu (CSR). AWS je podpisuje i odsyła nowy certyfikat razem z krótkim tokenem własności. Następnie urządzenie wywołuje provisioning template, przekazując ten token i kilka parametrów, na przykład swój numer seryjny. Szablon tworzy w rejestrze Thing, aktywuje nowy certyfikat, przypina do niego właściwą politykę i zwraca nazwę Thinga. Wtedy urządzenie się rozłącza, w codziennej pracy przestaje używać certyfikatu claim i łączy się ponownie, już z własną tożsamością.
Klucz prywatny do certyfikatu urządzenia powstaje na samym urządzeniu i nigdy go nie opuszcza. AWS oferuje też wariant, w którym klucz generuje chmura i wysyła go do urządzenia. Unikam go, bo na ESP32 ścieżka przez CSR kosztuje niewiele więcej, a znika dzięki niej jedyny moment, w którym klucz prywatny jest przesyłany.
Certyfikat claim, który prawie nic nie może
Certyfikat claim trafi na każde urządzenie, które wyślesz, więc od pierwszego dnia zakładam, że ktoś go w końcu wyciągnie. Jego polityka powinna pozwalać na połączenie i korzystanie z topików provisioningu, a poza tym na absolutnie nic.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Receive"],
"Resource": [
"arn:aws:iot:eu-central-1:123456789012:topic/$aws/certificates/create-from-csr/*",
"arn:aws:iot:eu-central-1:123456789012:topic/$aws/provisioning-templates/factory-template/provision/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:eu-central-1:123456789012:topicfilter/$aws/certificates/create-from-csr/*",
"arn:aws:iot:eu-central-1:123456789012:topicfilter/$aws/provisioning-templates/factory-template/provision/*"
]
}
]
}
Urządzenie, które ma tylko ten certyfikat, nie może odczytywać ani publikować telemetrii, nie ma dostępu do shadowów i nie widzi żadnych innych urządzeń. W najgorszym razie poprosi o nową tożsamość, a w kolejnych sekcjach pokazuję, jak sprawić, żeby takie żądanie przechodziło wyłącznie dla numerów seryjnych, które naprawdę zeszły z twojej linii.
Szablon provisioningu
Szablon opisuje, co AWS ma utworzyć dla każdego nowego urządzenia. Mój zwykle przyjmuje jako parametr numer seryjny, od niego nazywa Thinga i przypina jedną wspólną politykę dla urządzeń.
{
"Parameters": {
"SerialNumber": { "Type": "String" },
"AWS::IoT::Certificate::Id": { "Type": "String" }
},
"Resources": {
"thing": {
"Type": "AWS::IoT::Thing",
"Properties": {
"ThingName": { "Fn::Join": ["", ["sensor-", { "Ref": "SerialNumber" }]] },
"AttributePayload": { "serial": { "Ref": "SerialNumber" } }
}
},
"certificate": {
"Type": "AWS::IoT::Certificate",
"Properties": {
"CertificateId": { "Ref": "AWS::IoT::Certificate::Id" },
"Status": "ACTIVE"
}
},
"policy": {
"Type": "AWS::IoT::Policy",
"Properties": { "PolicyName": "fleet-device-policy" }
}
},
"DeviceConfiguration": {}
}
Do utworzenia tych zasobów szablon potrzebuje roli IAM, którą AWS IoT może przyjąć. Wystarczy rola z relacją zaufania do iot.amazonaws.com i przypiętą zarządzaną polityką AWSIoTThingsRegistration. Utworzenie szablonu i certyfikatu claim oraz przypięcie polityki claim to potem trzy polecenia.
aws iot create-provisioning-template \
--template-name factory-template \
--provisioning-role-arn arn:aws:iam::123456789012:role/IoTFleetProvisioningRole \
--template-body file://template.json \
--pre-provisioning-hook targetArn=arn:aws:lambda:eu-central-1:123456789012:function:check-serial \
--enabled
aws iot create-keys-and-certificate --set-as-active \
--certificate-pem-outfile claim.cert.pem \
--private-key-outfile claim.private.key \
--query certificateArn --output text
aws iot attach-policy --policy-name claim-policy --target <certificate-arn>
Funkcja Lambda podpięta jako hook potrzebuje jeszcze polityki zasobu, która pozwala iot.amazonaws.com ją wywołać. Załatwia to aws lambda add-permission.
Tylko te numery seryjne, które naprawdę wyprodukowałeś
Bez żadnej weryfikacji każdy, kto ma certyfikat claim, mógłby wymyślać numery seryjne i rejestrować dowolnie wiele fałszywych urządzeń. Tę lukę zamyka pre-provisioning hook. Zanim AWS cokolwiek utworzy, wywołuje funkcję Lambda z parametrami przesłanymi przez urządzenie i idzie dalej tylko wtedy, gdy funkcja się zgodzi.
Po stronie fabryki roboty jest na szczęście niewiele. Test końca linii i tak odczytuje z każdej płytki coś unikalnego, a na ESP32 jako numer seryjny świetnie sprawdza się adres MAC zapisany fabrycznie w eFuse. Stanowisko testowe wpisuje każdy numer seryjny do tabeli DynamoDB, gdy płytka przechodzi test, z flagą provisioned ustawioną na false. Hook akceptuje potem numer seryjny tylko raz i tylko wtedy, gdy jest on w tej tabeli. AWS daje hookowi pięć sekund na odpowiedź, a jeśli trwa to dłużej, odrzuca żądanie. Pojedynczy zapis do DynamoDB mieści się w tym limicie z dużym zapasem.
import boto3
table = boto3.resource("dynamodb").Table("factory-serials")
def handler(event, context):
serial = event.get("parameters", {}).get("SerialNumber", "")
try:
table.update_item(
Key={"serial": serial},
UpdateExpression="SET provisioned = :yes",
ConditionExpression="attribute_exists(serial) AND provisioned = :no",
ExpressionAttributeValues={":yes": True, ":no": False},
)
except table.meta.client.exceptions.ConditionalCheckFailedException:
return {"allowProvisioning": False}
return {"allowProvisioning": True}
Jednorazowość wymaga furtki awaryjnej. Jeśli urządzenie przejdzie przez hook, a potem straci zasilanie, zanim zapisze nowy certyfikat, przy następnej próbie zostanie odrzucone. Support musi więc mieć prosty sposób, żeby dezaktywować osierocony certyfikat przypięty do tego Thinga i wyzerować flagę dla jego numeru seryjnego. To okno pozostaje małe, jeśli zapiszesz klucz prywatny jeszcze przed wysłaniem CSR, a certyfikat od razu, gdy tylko przyjdzie.
Co robi urządzenie przy pierwszym uruchomieniu
Pierwszym krokiem na urządzeniu jest wygenerowanie pary kluczy i CSR. Poniższy kod jest napisany pod ESP-IDF 5.x, w którym jest mbedTLS 3.6, i zakłada, że generator liczb losowych CTR-DRBG został już zainicjowany. ESP-IDF 6.0 przeszedł na mbedTLS 4, gdzie klucze generuje się przez PSA crypto API, więc w tej wersji te wywołania trzeba przeportować. Używam klucza EC na krzywej P-256, który na mikrokontrolerze jest mniejszy i szybszy niż RSA.
mbedtls_pk_context key;
mbedtls_x509write_csr csr;
char subject[64];
unsigned char csr_pem[1024];
snprintf(subject, sizeof(subject), "CN=sensor-%s", serial);
mbedtls_pk_init(&key);
mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
mbedtls_ctr_drbg_random, &ctr_drbg);
mbedtls_x509write_csr_init(&csr);
mbedtls_x509write_csr_set_key(&csr, &key);
mbedtls_x509write_csr_set_md_alg(&csr, MBEDTLS_MD_SHA256);
mbedtls_x509write_csr_set_subject_name(&csr, subject);
mbedtls_x509write_csr_pem(&csr, csr_pem, sizeof(csr_pem),
mbedtls_ctr_drbg_random, &ctr_drbg);
Klucz prywatny trafia od razu do chronionej pamięci na urządzeniu. Na ESP32 oznacza to szyfrowany NVS przy włączonym flash encryption albo, w układach, które je mają, peryferium Digital Signature.
Następnie urządzenie łączy się, używając certyfikatu claim, i wymienia kilka wiadomości przez MQTT. Zanim cokolwiek wyśle, subskrybuje $aws/certificates/create-from-csr/json/accepted i odpowiadający mu topic rejected, a potem publikuje CSR na $aws/certificates/create-from-csr/json.
{
"certificateSigningRequest": "-----BEGIN CERTIFICATE REQUEST-----\nMIIB..."
}
Odpowiedź na topicu accepted zawiera podpisany certyfikat i token własności. Token jest ważny przez godzinę, a jeśli w tym czasie nowy certyfikat nie zostanie zarejestrowany, AWS go usuwa, więc urządzenie powinno od razu przejść do kolejnego kroku.
{
"certificateId": "8f2c...",
"certificatePem": "-----BEGIN CERTIFICATE-----\nMIIC...",
"certificateOwnershipToken": "eyJ2..."
}
Urządzenie subskrybuje topiki accepted i rejected pod $aws/provisioning-templates/factory-template/provision/json i publikuje na ten sam topic token razem ze swoim numerem seryjnym.
{
"certificateOwnershipToken": "eyJ2...",
"parameters": { "SerialNumber": "a4cf12b39e04" }
}
Jeśli hook zaakceptuje numer seryjny, w odpowiedzi przychodzi nazwa nowego Thinga.
{
"deviceConfiguration": {},
"thingName": "sensor-a4cf12b39e04"
}
Teraz urządzenie zapisuje certyfikat, rozłącza się i łączy ponownie, już z nowym certyfikatem, a jako MQTT client ID podaje nazwę Thinga. Dalej to już zwykła praca. Jeśli nie chcesz ręcznie pisać obsługi topików, AWS udostępnia niewielką bibliotekę Fleet Provisioning w C, która buduje i parsuje dokładnie te topiki i współpracuje z dowolnym klientem MQTT. Repozytorium esp-aws-iot Espressifa zawiera ją razem z przykładem fleet_provisioning_with_csr, który w chwili pisania tego tekstu działa z ESP-IDF 5.x, ale jeszcze nie z 6.0.
Jedna polityka dla wszystkich urządzeń
Szablon przypina całej flocie tę samą politykę, dlatego korzysta ona ze zmiennych, żeby każde urządzenie mogło występować wyłącznie jako ono samo. Szablon przypina też Thinga do certyfikatu, więc AWS potrafi ustalić nazwę Thinga w chwili połączenia.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:Receive"],
"Resource": "arn:aws:iot:eu-central-1:123456789012:topic/devices/${iot:Connection.Thing.ThingName}/*"
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": "arn:aws:iot:eu-central-1:123456789012:topicfilter/devices/${iot:Connection.Thing.ThingName}/*"
}
]
}
AWS IoT rozpoznaje Thinga po certyfikacie, którym przedstawia się urządzenie, więc urządzenie może występować tylko jako Thing przypięty do własnego certyfikatu, a połączenie zostanie przyjęte tylko wtedy, gdy jego client ID zgadza się z nazwą tego Thinga.
Jak dbać o złoty certyfikat
Certyfikat claim to najcenniejszy sekret w całym rozwiązaniu, więc chronię go tak samo jak sam firmware. Secure Boot i flash encryption na ESP32 znacznie utrudniają odczytanie go z płytki. Dla każdej partii produkcyjnej albo każdego producenta wystawiam też osobny certyfikat claim. Jeśli któryś wycieknie, szkody są ograniczone, a stary certyfikat mogę dezaktywować, gdy wszystkie urządzenia z danej partii już się połączyły. Przydaje się też śledzenie liczby provisioningów. Nagły wysyp żądań o trzeciej w nocy albo hook odrzucający wiele nieznanych numerów seryjnych to dobry powód do alertu.
Warto też wcześnie rozstrzygnąć, kiedy urządzenia mają przechodzić provisioning. Może się to odbywać podczas testu końca linii, gdy są jeszcze w fabryce i w znanej sieci, albo później, u klienta. Provisioning w fabryce daje pewność, że każda wysłana sztuka ma już swoją tożsamość, i pozwala dezaktywować certyfikat claim zaraz po zakończeniu partii. Provisioning w terenie upraszcza proces fabryczny i pasuje do produktów, które miesiącami leżą w magazynie, ale certyfikat claim musi wtedy dłużej pozostać aktywny.
Kiedy wybrać coś innego
Złoty certyfikat sprawdza się w większości małych i średnich serii produkcyjnych i właśnie to zasugerowałbym użytkownikowi ThingsBoard, od którego zacząłem ten wpis. ThingsBoard ma zresztą ten sam mechanizm wbudowany: przy pierwszym połączeniu urządzenie wysyła klucz i sekret provisioningu ze swojego profilu urządzenia, a w zamian dostaje własne dane uwierzytelniające, więc ten wzorzec działa także poza AWS. Przy większych wolumenach albo w produktach z rygorystycznymi wymaganiami bezpieczeństwa są jeszcze układy typu secure element, na przykład ATECC608B Microchipa w wariancie Trust&GO. Przychodzą prosto od producenta układu z unikalnym kluczem prywatnym zamkniętym w środku i pasującym do niego certyfikatem, co całkowicie eliminuje wspólny sekret. Microchip dostarcza manifest tych certyfikatów, który można zbiorczo zarejestrować w AWS IoT jeszcze przed wysyłką urządzeń. Takie rozwiązania kosztują więcej na sztukę i dokładają zależność w łańcuchu dostaw, więc w wielu produktach rozsądnie jest zacząć od certyfikatu claim ze starannie napisanym hookiem.
Jeśli właśnie przenosisz urządzenie IoT z fazy prototypów do pierwszej partii produkcyjnej i chcesz, żeby ktoś jeszcze rzucił okiem na proces provisioningu, chętnie pomogę.