AWS IoT Device Enrollment - jaycp30/esp32-fdas-aws-iot GitHub Wiki
How an ESP32 board gets an identity in AWS IoT Core, gets permission to publish, and connects.
This is the manual version of what infra/iot-input-device.yaml does with CloudFormation. Follow
it once by hand to understand every piece, then use the template for real devices.
Example device: fdas-iot-ane1-input-01 (the input module), region ap-northeast-1 (Tokyo).
Enrolling a device uses the same trust model as SSH key login, plus a sudoers file.
| AWS IoT piece | What it is | SSH analogy |
|---|---|---|
| Private key | The device's secret. Never leaves the device. | ~/.ssh/id_ed25519 |
| Certificate | The device's public key, signed by AWS, like an ID card | an authorized_keys entry |
| Thing | The device's name in AWS's registry | the username |
| Policy | What the device may do: connect as X, publish to Y |
sudoers / an IAM policy |
| Endpoint | Your account's MQTT server address (xxxx-ats.iot.<region>.amazonaws.com) |
the server hostname |
| Amazon Root CA | How the device checks it's talking to the real AWS | known_hosts |
Enrollment = create those pieces, link them (policy โ certificate โ thing), and load four items into the device: the endpoint, the Root CA, the certificate and the private key.
- AWS CLI v2 signed in to the right account. Check:
aws sts get-caller-identity. -
openssl(macOS and Linux have it). - A working folder that is not inside a git repo, because it will hold a private key.
Set the names once. Every command below uses these variables, so nothing needs editing:
THING=fdas-iot-ane1-input-01 REGION=ap-northeast-1ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text --region "$REGION")AWS never sees this file.
openssl ecparam -name prime256v1 -genkey -noout -out device.keychmod 600 device.keyThe CSR holds only the public half of the key plus the device's name. It's safe to send.
openssl req -new -key device.key -subj "/CN=$THING" -out device.csrAWS returns a certificate and marks it ACTIVE. Keep its ARN; the next steps use it.
CERT_ARN=$(aws iot create-certificate-from-csr --certificate-signing-request file://device.csr --set-as-active --region "$REGION" --query certificateArn --output text)Download the signed certificate. ${CERT_ARN##*/} means "the part after the last /", which is
the certificate ID:
aws iot describe-certificate --certificate-id "${CERT_ARN##*/}" --region "$REGION" --query certificateDescription.certificatePem --output text > device.crtProve the certificate belongs to your key. This compares public keys only; the private key is never printed:
diff <(openssl x509 -in device.crt -noout -pubkey) <(openssl pkey -in device.key -pubout) && echo MATCHWhy not
aws iot create-keys-and-certificate? It's a one-step shortcut where AWS generates the private key and sends it to you once. It works, but the key then travels over the network. The CSR route above keeps the key on your machine from birth.
aws iot create-thing --thing-name "$THING" --region "$REGION"Least privilege, taken from the project's MQTT contract: the device may connect only as its own name and publish only to its own two topics. It can't subscribe to anything.
The \$ before {iot:Connection.Thing.ThingName} keeps it as literal text for AWS to fill in. It's
a policy variable, not a shell variable. ${REGION} and ${ACCOUNT_ID} are filled in by your
shell.
cat > policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:${REGION}:${ACCOUNT_ID}:client/\${iot:Connection.Thing.ThingName}",
"Condition": { "Bool": { "iot:Connection.Thing.IsAttached": "true" } }
},
{
"Effect": "Allow",
"Action": ["iot:Publish", "iot:RetainPublish"],
"Resource": [
"arn:aws:iot:${REGION}:${ACCOUNT_ID}:topic/fdas/\${iot:Connection.Thing.ThingName}/state",
"arn:aws:iot:${REGION}:${ACCOUNT_ID}:topic/fdas/\${iot:Connection.Thing.ThingName}/status"
]
}
]
}
EOFCheck it's valid JSON and the policy variable survived (expect 3):
python3 -m json.tool policy.json > /dev/null && grep -c 'iot:Connection.Thing.ThingName' policy.jsonaws iot create-policy --policy-name "$THING-policy" --policy-document file://policy.json --region "$REGION""This ID card has these permissions."
aws iot attach-policy --policy-name "$THING-policy" --target "$CERT_ARN" --region "$REGION""This ID card belongs to this device." The policy's IsAttached condition depends on this link.
aws iot attach-thing-principal --thing-name "$THING" --principal "$CERT_ARN" --region "$REGION"ENDPOINT=$(aws iot describe-endpoint --endpoint-type iot:Data-ATS --region "$REGION" --output text)On macOS, export it from the system's built-in trusted roots (no download needed):
security find-certificate -c "Amazon Root CA 1" -p /System/Library/Keychains/SystemRootCertificates.keychain > AmazonRootCA1.pemOn Linux or Windows (WSL), download it from Amazon:
curl -fsS -o AmazonRootCA1.pem https://www.amazontrust.com/repository/AmazonRootCA1.pemEither way, check the fingerprint ends in โฆ:AE:98:19:6E
(full value: 8E:CD:E6:88:4F:3D:87:B1:12:5B:A3:1A:C3:FC:B1:3D:70:16:DE:7F:57:CC:90:4F:E1:CB:97:C6:AE:98:19:6E):
openssl x509 -in AmazonRootCA1.pem -noout -subject -fingerprint -sha256Put the four items into ESPHome's secrets.yaml (in ~/esphome/, never in git). Certificates
and keys are multi-line, so use YAML's | block style:
mqtt_broker: xxxx-ats.iot.ap-northeast-1.amazonaws.com # the $ENDPOINT from step 8
aws_root_ca: |
-----BEGIN CERTIFICATE-----
...contents of AmazonRootCA1.pem...
-----END CERTIFICATE-----
mqtt_client_cert: |
-----BEGIN CERTIFICATE-----
...contents of device.crt...
-----END CERTIFICATE-----
mqtt_client_key: |
-----BEGIN EC PRIVATE KEY-----
...contents of device.key...
-----END EC PRIVATE KEY-----Then lock the file to your user only:
chmod 600 ~/esphome/secrets.yamlAnd in the device config (see firmware/smart-input-module.yaml for the full version):
mqtt:
broker: !secret mqtt_broker
port: 8883 # MQTT over TLS
client_id: fdas-iot-ane1-input-01 # MUST equal the thing name (see the policy)
certificate_authority: !secret aws_root_ca
client_certificate: !secret mqtt_client_cert
client_certificate_key: !secret mqtt_client_key
discovery: false # no Home Assistant topics: the policy would reject them
topic_prefix: null # no automatic per-entity topics
log_topic: null # no log topicClient-certificate TLS in ESPHome needs framework: type: esp-idf. Compile and flash as usual.
Once the board is online, read back what it published, straight from AWS:
aws iot-data get-retained-message --topic "fdas/$THING/status" --endpoint-url "https://$ENDPOINT" --region "$REGION" --query payload --output text | base64 --decodeExpected: {"v":1,"status":"online"}. For a live view, open the AWS console โ IoT Core โ MQTT
test client, and subscribe to fdas/#.
ESP32 AWS IoT Core
โ 1. TLS: "who are you?" โโโโโโโโโโโโโโโโโโโโบ โ
โ โโโ AWS shows its server certificate โ
โ 2. Checks it against Amazon Root CA โ (am I talking to the real AWS?)
โ 3. Shows device.crt and proves it owns โ
โ device.key by signing a challenge โโโโโโโโบ โ 4. Is this cert ACTIVE? Which thing?
โ โ Which policy?
โ 5. MQTT CONNECT, client ID = thing name โโโโโบ โ 6. Policy: client ID = thing name? โ
โ 7. PUBLISH fdas/<name>/state โโโโโโโโโโโโโโโโบ โ 8. Policy: topic allowed? โ โ delivered
โ (anything not allowed โ disconnected) โ
Both sides prove who they are: that's mutual TLS. Note step 8: AWS IoT disconnects a client that publishes or subscribes outside its policy, instead of returning an error. In a log, a permission problem therefore looks like a connection problem.
Why a stolen certificate is less useful than it sounds: the policy only allows connecting with the client ID that equals the thing name, and only publishing to that device's own topics. A copied certificate can impersonate that one board's reports and nothing else. That's why you deactivate a certificate as soon as a board is lost.
Cut a device off immediately (its next connection attempt is refused):
aws iot update-certificate --certificate-id "${CERT_ARN##*/}" --new-status INACTIVE --region "$REGION"Rotate the key: repeat steps 1โ3 and 6โ7 for a new certificate, flash it, confirm the board is online, then revoke and delete the old one.
Tear down in reverse order. AWS refuses to delete anything that's still linked:
aws iot detach-thing-principal --thing-name "$THING" --principal "$CERT_ARN" --region "$REGION"aws iot detach-policy --policy-name "$THING-policy" --target "$CERT_ARN" --region "$REGION"aws iot update-certificate --certificate-id "${CERT_ARN##*/}" --new-status INACTIVE --region "$REGION"aws iot delete-certificate --certificate-id "${CERT_ARN##*/}" --region "$REGION"aws iot delete-thing --thing-name "$THING" --region "$REGION"aws iot delete-policy --policy-name "$THING-policy" --region "$REGION"infra/iot-input-device.yaml creates steps 3โ7 (certificate from your CSR, thing, policy, both
links) as one stack. Steps 1โ2 (key + CSR) and 8โ9 (endpoint, Root CA) stay manual, so the
private key never goes near AWS.
aws cloudformation deploy --stack-name "$THING" --template-file infra/iot-input-device.yaml --region "$REGION" --parameter-overrides ThingName="$THING" "CertificateSigningRequest=$(cat device.csr)"Teardown becomes one command, with CloudFormation handling the order:
aws cloudformation delete-stack --stack-name "$THING" --region "$REGION"-
+and#are literal in IoT policies.topicfilter/fdas/+/stateonly allows subscribing to the exact textfdas/+/state. Use the IAM wildcard*:topicfilter/fdas/*/state. - "Socket closed" right after connecting usually means the policy, not the network. Check the client ID, the topic names and the subscribe filters.
-
The client ID must equal the thing name, or
iot:Connectis denied (policy variable${iot:Connection.Thing.ThingName}). - The last will fires about 1.5 ร the keep-alive after power loss. With a 30 s keep-alive, we measured 47 s.
-
Never commit
device.key, and never share the compiled firmware file: it contains the private key and the Wi-Fi password.
Related: docs/mqtt-contract.md
(what the board publishes) ยท
infra/iot-input-device.yaml