AWS IoT Device Enrollment - jaycp30/esp32-fdas-aws-iot GitHub Wiki

AWS IoT Device Enrollment

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).


1. The idea in one table

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.


2. Before you start

  • 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-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text --region "$REGION")

3. Step by step

Step 1: Generate the private key on your own machine

AWS never sees this file.

openssl ecparam -name prime256v1 -genkey -noout -out device.key
chmod 600 device.key

Step 2: Create a certificate signing request (CSR)

The 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.csr

Step 3: Ask AWS to sign it โ†’ the certificate

AWS 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.crt

Prove 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 MATCH

Why 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.

Step 4: Register the thing (the name)

aws iot create-thing --thing-name "$THING" --region "$REGION"

Step 5: Create the policy (the permissions)

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"
      ]
    }
  ]
}
EOF

Check 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.json
aws iot create-policy --policy-name "$THING-policy" --policy-document file://policy.json --region "$REGION"

Step 6: Link policy โ†’ certificate

"This ID card has these permissions."

aws iot attach-policy --policy-name "$THING-policy" --target "$CERT_ARN" --region "$REGION"

Step 7: Link certificate โ†’ thing

"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"

Step 8: Look up your account's endpoint

ENDPOINT=$(aws iot describe-endpoint --endpoint-type iot:Data-ATS --region "$REGION" --output text)

Step 9: Get Amazon Root CA 1

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.pem

On Linux or Windows (WSL), download it from Amazon:

curl -fsS -o AmazonRootCA1.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem

Either 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 -sha256

4. Load it into the device (ESPHome)

Put 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.yaml

And 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 topic

Client-certificate TLS in ESPHome needs framework: type: esp-idf. Compile and flash as usual.


5. Test it

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 --decode

Expected: {"v":1,"status":"online"}. For a live view, open the AWS console โ†’ IoT Core โ†’ MQTT test client, and subscribe to fdas/#.


6. What happens when the board connects

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.


7. Revoke, rotate, tear down

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"

8. How this repo does it: CloudFormation

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"

9. Gotchas we hit

  • + and # are literal in IoT policies. topicfilter/fdas/+/state only allows subscribing to the exact text fdas/+/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:Connect is 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

โš ๏ธ **GitHub.com Fallback** โš ๏ธ