こんにちは。CX事業本部Delivery部サーバーサイドチームの木村です。
はじめに
AWSにはデバイスに認証情報をプロビジョニングする方法がいくつか用意されています。
https://pages.awscloud.com/rs/112-TZM-766/images/EV_iot-deepdive-aws2_Sep-2020.pdf#page=33
今回は図中のフリートプロビジョニング登録のうち、「信頼されたユーザーによるプロビジョニング」を試してみます。 さらに詳細が分かれますが、下図のパターンを試してみます。
関連するリソースはCDKv2で準備します。デバイス側の動作はMQTTクライアントであるmosquittoを使用し確認します。
主なバージョン関連は以下のとおりです。
項目 | バージョン |
---|---|
OS | macOS Monterey 12.6.1 |
node | 16.14.0 |
mosquitto | 2.0.15 |
mqtt | 3.1.1 |
aws-cdk | 2.64.0 |
aws-sdk | 3.267.0 |
概要
大まかな流れは以下のとおりです。
Cognitoを用いてユーザーの認証と認可を行い、AWSの一次トークンを取得します。
そのトークンを使用して、5分間だけ有効なクレーム証明書と対応するキーペアを取得します。
そのクレーム証明書を使用して、実際にデバイスが使用するデバイス証明書を取得します。
関連リソースのデプロイ
CDK
import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as iot from 'aws-cdk-lib/aws-iot';
import dedent from "ts-dedent";
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
export class CdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const userPool = new cognito.UserPool(this, 'sample-user-pool-20230217', {
userPoolName: 'sample-user-pool-20230217',
signInCaseSensitive: false,
selfSignUpEnabled: false,
signInAliases: {
email: true,
},
autoVerify: { email: true },
keepOriginal: {
email: true,
},
mfa: cognito.Mfa.OFF,
passwordPolicy: {
minLength: 8,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
tempPasswordValidity: cdk.Duration.days(3),
},
accountRecovery: cognito.AccountRecovery.NONE,
email: cognito.UserPoolEmail.withCognito('no-reply@verificationemail.com'),
});
const userPoolClient = userPool.addClient('app-client', {
supportedIdentityProviders: [
cognito.UserPoolClientIdentityProvider.COGNITO,
],
});
const identityPool = new cognito.CfnIdentityPool(this, 'sample-identity-pool-20230217', {
identityPoolName: 'sample-identity-pool-20230217',
allowUnauthenticatedIdentities: false,
cognitoIdentityProviders: [
{
clientId: userPoolClient.userPoolClientId,
providerName: userPool.userPoolProviderName,
},
],
});
const unauthenticatedRole = new iam.Role(
this,
'sample-unauthenticated-role-20230217',
{
assumedBy: new iam.FederatedPrincipal(
'cognito-identity.amazonaws.com',
{
StringEquals: {
'cognito-identity.amazonaws.com:aud': identityPool.ref,
},
'ForAnyValue:StringLike': {
'cognito-identity.amazonaws.com:amr': 'unauthenticated',
},
},
'sts:AssumeRoleWithWebIdentity',
),
},
);
unauthenticatedRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"mobileanalytics:PutEvents",
"cognito-sync:*",
"cognito-identity:*"
],
resources: ["*"]
}))
const authenticatedRole = new iam.Role(this, 'sample-authenticated-role-20230217', {
assumedBy: new iam.FederatedPrincipal(
'cognito-identity.amazonaws.com',
{
StringEquals: {
'cognito-identity.amazonaws.com:aud': identityPool.ref,
},
'ForAnyValue:StringLike': {
'cognito-identity.amazonaws.com:amr': 'authenticated',
},
},
'sts:AssumeRoleWithWebIdentity',
),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AWSIoTFullAccess'),
],
});
authenticatedRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"mobileanalytics:PutEvents",
"cognito-sync:*",
"cognito-identity:*"
],
resources: ["*"]
}))
new cognito.CfnIdentityPoolRoleAttachment(
this,
'identity-pool-role-attachment',
{
identityPoolId: identityPool.ref,
roles: {
authenticated: authenticatedRole.roleArn,
unauthenticated: unauthenticatedRole.roleArn,
},
roleMappings: {
mapping: {
type: 'Token',
ambiguousRoleResolution: 'AuthenticatedRole',
identityProvider: `cognito-idp.${
cdk.Stack.of(this).region
}.amazonaws.com/${userPool.userPoolId}:${
userPoolClient.userPoolClientId
}`,
},
},
},
);
const iotPolicyForCert = new iot.CfnPolicy(this, 'sample-iot-policy-for-cert-20230217', {
policyDocument: {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:*",
"Resource": "*"
}
]
},
policyName: 'sample-iot-policy-for-cert-20230217',
});
const provisioningRole = new iam.Role(this, 'provisioningRole', {
assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'),
})
provisioningRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSIoTThingsRegistration'));
const preProvisioningFunction = new lambda.Function(this, 'pre-provisioning-function', {
code: new lambda.InlineCode(dedent`
exports.handler = async () => {
// ここでチェックを行う
return {
"allowProvisioning": true
}
}
`),
handler: 'index.handler',
runtime: lambda.Runtime.NODEJS_16_X,
});
preProvisioningFunction.grantInvoke(new iam.ServicePrincipal('iot.amazonaws.com'))
const cfnProvisioningTemplate = new iot.CfnProvisioningTemplate(this, 'sample-provisioning-template-20230217', {
provisioningRoleArn: provisioningRole.roleArn,
templateBody: `{"Parameters":{"SerialNumber":{"Type":"String"},"AWS::IoT::Certificate::Id":{"Type":"String"}},"Resources":{"policy_${iotPolicyForCert.policyName}":{"Type":"AWS::IoT::Policy","Properties":{"PolicyName":"${iotPolicyForCert.policyName}"}},"certificate":{"Type":"AWS::IoT::Certificate","Properties":{"CertificateId":{"Ref":"AWS::IoT::Certificate::Id"},"Status":"Active"}},"thing":{"Type":"AWS::IoT::Thing","OverrideSettings":{"AttributePayload":"MERGE","ThingGroups":"DO_NOTHING","ThingTypeName":"REPLACE"},"Properties":{"AttributePayload":{},"ThingGroups":[],"ThingName":{"Fn::Join":["",["Fleet_Index_",{"Ref":"SerialNumber"}]]}}}}}`,
enabled: true,
preProvisioningHook: {
payloadVersion: '2020-04-01',
targetArn: preProvisioningFunction.functionArn,
},
templateName: 'sample-provision-template-20230217',
templateType: 'FLEET_PROVISIONING',
});
// Amplifyの設定で使用します。 ... (1)
new cdk.CfnOutput(this, 'identity-pool-id-output', {
value: identityPool.ref!,
exportName: 'identityPoolId',
});
// Amplifyの設定で使用します。 ... (2)
new cdk.CfnOutput(this, 'user-pool-id-output', {
value: userPool.userPoolId,
exportName: 'userPoolId',
});
// Amplifyの設定で使用します。 ... (3)
new cdk.CfnOutput(this, 'user-pool-web-client-id-output', {
value: userPoolClient.userPoolClientId,
exportName: 'userPoolWebClientId',
});
// Amplifyの設定, mosquittoでの検証で使用します。 ... (4)
new cdk.CfnOutput(this, 'provisioning-template-name-output', {
value: cfnProvisioningTemplate.templateName!,
exportName: 'provisioningTemplateName',
});
}
}
Cognito
Emailでログインするシンプルな構成にしています。
テンプレートに割り当てるIoTポリシー
「AWSIoTThingsRegistration」マネージドポリシーを指定しています。
const provisioningRole = new iam.Role(this, 'provisioningRole', {
assumedBy: new iam.ServicePrincipal('iot.amazonaws.com'),
})
provisioningRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSIoTThingsRegistration'));
Fleet Provisioningのテンプレート
設定しているテンプレートをフォーマットすると以下の通りです。
{
"Parameters": {
"SerialNumber": {
"Type": "String"
},
"AWS::IoT::Certificate::Id": {
"Type": "String"
}
},
"Resources": {
"policy_${iotPolicyForCert.policyName}": {
"Type": "AWS::IoT::Policy",
"Properties": {
"PolicyName": "${iotPolicyForCert.policyName}"
}
},
"certificate": {
"Type": "AWS::IoT::Certificate",
"Properties": {
"CertificateId": {
"Ref": "AWS::IoT::Certificate::Id"
},
"Status": "Active"
}
},
"thing": {
"Type": "AWS::IoT::Thing",
"OverrideSettings": {
"AttributePayload": "MERGE",
"ThingGroups": "DO_NOTHING",
"ThingTypeName": "REPLACE"
},
"Properties": {
"AttributePayload": {},
"ThingGroups": [],
"ThingName": {
"Fn::Join": [
"",
[
"Fleet_Index_",
{
"Ref": "SerialNumber"
}
]
]
}
}
}
}
}
モノにアタッチされるポリシーを指定しています。
"policy_${iotPolicyForCert.policyName}": {
"Type": "AWS::IoT::Policy",
"Properties": {
"PolicyName": "${iotPolicyForCert.policyName}"
}
}
モノに関する記述は以下のとおりです。 「Fleet_Index_」というプレフィックスと後ほど証明書の取得時に指定する「SerialNumber」でモノの名前が構成されるように修正しています。
"thing": {
"Type": "AWS::IoT::Thing",
"OverrideSettings": {
"AttributePayload": "MERGE",
"ThingGroups": "DO_NOTHING",
"ThingTypeName": "REPLACE"
},
"Properties": {
"AttributePayload": {},
"ThingGroups": [],
"ThingName": {
"Fn::Join": [
"",
[
"Fleet_Index_",
{
"Ref": "SerialNumber"
}
]
]
}
}
}
Web App
create-react-appで作成したプロジェクトを使用します。
$ npx create-react-app sample-app --template typescript --use-yarn
主要な部分は以下のとおりです。
src/App.ts
import { FC } from "react";
import { Amplify, Auth } from "aws-amplify";
import {
withAuthenticator,
WithAuthenticatorProps,
} from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
import { IoTClient, CreateProvisioningClaimCommand } from "@aws-sdk/client-iot";
Amplify.configure({
Auth: {
identityPoolId: "CDKのoutput(1)",
region: "ap-northeast-1",
userPoolId: "CDKのoutput(2)",
userPoolWebClientId: "CDKのoutput(3)",
},
});
const App: FC = ({ signOut, user }: WithAuthenticatorProps) => {
const handleProvisionClaimCert = async (user: any) => {
const credentials = await Auth.currentCredentials();
const client = new IoTClient({
region: "ap-northeast-1",
credentials: Auth.essentialCredentials(credentials),
});
const command = new CreateProvisioningClaimCommand({
templateName: "CDKのoutput(4)",
});
const response = await client.send(command);
console.log(response);
const element = document.createElement("a");
const file = new Blob([response.certificatePem ?? ""], {
type: "text/plain",
});
element.href = URL.createObjectURL(file);
element.download = "claimCert.pem";
document.body.appendChild(element); // Required for this to work in FireFox
element.click();
const element2 = document.createElement("a");
const file2 = new Blob([response.keyPair?.PrivateKey ?? ""], {
type: "text/plain",
});
element2.href = URL.createObjectURL(file2);
element2.download = "claimPrivateKey.pem";
document.body.appendChild(element2); // Required for this to work in FireFox
element2.click();
};
return (
<>
<h1>Hello {user?.username}</h1>
<button onClick={signOut}>Sign out</button>
<hr />
<button onClick={() => handleProvisionClaimCert(user)}>
download claim cert
</button>
</>
);
};
export default withAuthenticator(App);
「@aws-sdk/client-iot」を使用します。認証情報はCognitoで認証認可したユーザーのクレデンシャルを使用します。
const client = new IoTClient({
region: "ap-northeast-1",
credentials: Auth.essentialCredentials(credentials),
});
「download claim cert」を押下すると、クレーム証明書と秘密鍵をダウンロードするようにしています。
<button onClick={() => handleProvisionClaimCert(user)}>
download claim cert
</button>
動作確認
mosquitto_rrコマンドを使用して、レスポンス用のトピックへの応答も確認します。
Amazon管理のCA証明書の取得
$ curl -o ~/Desktop/AmazonRootCA1.pem -O https://www.amazontrust.com/repository/AmazonRootCA1.pem
AWS IoTのデータエンドポイントを確認
$ aws iot describe-endpoint --endpoint-type iot:Data-ATS
クレーム証明書を取得する
「$aws/certificates/create/json」トピックに対してパブリッシュします。 「$aws/certificates/create/json/accepted」トピックをサブスクライブしておくと、
- 証明書
- キーペア
- 証明書所有者トークン
を取得できます。
マネジメントコンソールで証明書を確認すると、「保留中」状態のままになっています。
$ mosquitto_rr \
--cert ~/Downloads/claimCert.pem \
--key ~/Downloads/claimPrivateKey.pem \
--cafile ~/Downloads/AmazonRootCA1.pem \
-d \
-h xxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com \
-p 8883 \
-t "\$aws/certificates/create/json" \
-m '{}' \
-e "\$aws/certificates/create/json/accepted" \
-i sample-client-id-1 \
--protocol-version mqttv311 \
-q 1
デバイス証明書を取得する
証明書所有者トークンとモノの作成に必要なパラメーターをメッセージに含めてパブリッシュします。
マネジメントコンソールで証明書を確認すると、「アクティブ」状態になり、モノが作成されます。
$ mosquitto_rr \
--cert ~/Downloads/claimCert.pem \
--key ~/Downloads/claimPrivateKey.pem \
--cafile ~/Downloads/AmazonRootCA1.pem \
-d \
-h xxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com \
-p 8883 \
-t "\$aws/provisioning-templates/<< CDKのoutput(4) >>/provision/json" \
-m '{"certificateOwnershipToken":"<<「クレーム証明書を取得する」で取得した証明書所有者トークン>>","parameters":{"SerialNumber": "sampleSerialNumber"}}' \
-e "\$aws/provisioning-templates/<< CDKのoutput(4) >>/provision/json/accepted" \
--protocol-version mqttv311 \
-i sample-client-id-1 \
-q 1
最後に
「信頼されたユーザーによるプロビジョニング」について試してみました。
- 事前に証明書をデバイスに配置することができない
- プロビジョニング時にユーザーの認証が必要である
- Amazon管理のCA証明書のしようで問題がない
などのユースケースにはフィットするのではないかと思います。
一方で、テンプレートによる設定管理は、どこを変えるとどう変わるかの把握するのが難しいなと個人的には感じており、開発やデバッグには十分な時間を取る必要があるなと感じました。
どなたかのお役に立てれば幸いです。
参考
https://docs.aws.amazon.com/ja_jp/iot/latest/developerguide/provision-wo-cert.html
https://pages.awscloud.com/rs/112-TZM-766/images/EV_iot-deepdive-aws2_Sep-2020.pdf