Welcome to telecotele.com » Projects

ESP32-C3のセキュリティ機能を試す(概要・予習編)

ESP32-C3で利用可能な「セキュアブート」「フラッシュ暗号化」「NVS暗号化」の概要をまとめ、エミュレータで試します。

tags: esp WORKING

ESP32-C3のセキュリティ機能を試します。 具体的には「セキュアブート」「フラッシュ暗号化」「NVS暗号化」についてです。

最終的には実機でArduinoフレームワークを使ってこれらの機能を試すはずでした。 ただ、記事が長くなってしまったため、 今回はセキュリティ機能のまとめと、それらの機能をエミュレータでESP-IDFフレームワークを使って試すのみで留めています。実機についてはまた別の記事で。

セキュリティ機能

ESP32-C3で使われるセキュリティ機能についてまとめます。 主な情報元は下記の公式プログラミングガイドです。

Security Guides - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/security/index.html

ガイド的には「ESP32-C3」と「ESP32」は違うデバイスという扱いのようで、内容もデバイスに応じて微妙に違うことがあるので注意が必要です。 パスやサイドバーの表示を確認し、ちゃんと目的のデバイスに合ったページになっているか確認しましょう。 適当にググって出たページを開いて、うっかり違うデバイスの内容を見ていたとならないように。

また、下記の(ESP32-C3ではなくESP32-S3についての)発表も参考にしています。

#分解のススメ 17回 たまごっちユニの分解からわかるESP32デバイスセキュリティ @ciniml ​ - YouTube
https://www.youtube.com/watch?v=4Lyf3GsVvp8

eFuse

まずはeFuseについてです。 過電流保護を目的として電源回路に用いられる “eFuse IC”1とは関係ありません。

ここでいうeFuseは1度だけ書き込み可能な、いわゆるOTP (One Time Programmable) の不揮発性メモリです。 ADコンバータ向けのキャリブレーションデータが(工場出荷時に書かれて)載っているほか、 開発者側でも各種機能を有効/無効にするパラメータや暗号化に使う鍵などが書けます。

各ビット単位でOTPの動きとなり、あるビットを一度 “1” とすると “0” に戻すことはできません。 慎重に、注意深く操作しないとならない。 とはいえ、機能の有効/無効などが必ずしも特定の1ビットと紐づくわけではなく「ある範囲(数ビット分)のビットカウントが奇数なら有効、偶数なら無効」などで判定されている場合もあります。 この場合、うっかり1ビット意図せず書き込んでしまったとしても、残りのビットが未書き込みであればまだ大丈夫です。

eFuseの中身は、公式開発環境のESP-IDFに付属するツールで(書き込み済みの鍵など読み取り保護されている領域を除いて)読み出しもできます。

実行例(一部マスク。長いので折りたたみ)
> espefuse.py --port COM6 summary
espefuse.py v4.8.1
Connecting...
Detecting chip type... ESP32-C3

=== Run "summary" command ===
EFUSE_NAME (Block) Description  = [Meaningful Value] [Readable/Writeable] (Hex Value)
----------------------------------------------------------------------------------------
Calibration fuses:
K_RTC_LDO (BLOCK1)                                 BLOCK1 K_RTC_LDO                                   = -20 R/W (0b1000101)
K_DIG_LDO (BLOCK1)                                 BLOCK1 K_DIG_LDO                                   = -16 R/W (0b1000100)
V_RTC_DBIAS20 (BLOCK1)                             BLOCK1 voltage of rtc dbias20                      = -32 R/W (0x88)
V_DIG_DBIAS20 (BLOCK1)                             BLOCK1 voltage of digital dbias20                  = -16 R/W (0x84)
DIG_DBIAS_HVT (BLOCK1)                             BLOCK1 digital dbias when hvt                      = -20 R/W (0b10101)
THRES_HVT (BLOCK1)                                 BLOCK1 pvt threshold when hvt                      = 1800 R/W (0b0111000010)
TEMP_CALIB (BLOCK2)                                Temperature calibration data                       = -2.0 R/W (0b100010100)
OCODE (BLOCK2)                                     ADC OCode                                          = 65 R/W (0x41)
ADC1_INIT_CODE_ATTEN0 (BLOCK2)                     ADC1 init code at atten0                           = 1648 R/W (0b0110011100)
ADC1_INIT_CODE_ATTEN1 (BLOCK2)                     ADC1 init code at atten1                           = -176 R/W (0b1000101100)
ADC1_INIT_CODE_ATTEN2 (BLOCK2)                     ADC1 init code at atten2                           = -272 R/W (0b1001000100)
ADC1_INIT_CODE_ATTEN3 (BLOCK2)                     ADC1 init code at atten3                           = -752 R/W (0b1010111100)
ADC1_CAL_VOL_ATTEN0 (BLOCK2)                       ADC1 calibration voltage at atten0                 = -220 R/W (0b1000110111)
ADC1_CAL_VOL_ATTEN1 (BLOCK2)                       ADC1 calibration voltage at atten1                 = -16 R/W (0b1000000100)
ADC1_CAL_VOL_ATTEN2 (BLOCK2)                       ADC1 calibration voltage at atten2                 = -220 R/W (0b1000110111)
ADC1_CAL_VOL_ATTEN3 (BLOCK2)                       ADC1 calibration voltage at atten3                 = -380 R/W (0b1001011111)

Config fuses:
WR_DIS (BLOCK0)                                    Disable programming of individual eFuses           = 0 R/W (0x00000000)
RD_DIS (BLOCK0)                                    Disable reading from BlOCK4-10                     = 0 R/W (0b0000000)
DIS_ICACHE (BLOCK0)                                Set this bit to disable Icache                     = False R/W (0b0)
DIS_TWAI (BLOCK0)                                  Set this bit to disable CAN function               = False R/W (0b0)
DIS_DIRECT_BOOT (BLOCK0)                           Disable direct boot mode                           = False R/W (0b0)
UART_PRINT_CONTROL (BLOCK0)                        Set the default UARTboot message output mode       = Disable R/W (0b11)
ERR_RST_ENABLE (BLOCK0)                            Use BLOCK0 to check error record registers         = with check R/W (0b1)
BLOCK_USR_DATA (BLOCK3)                            User data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_SYS_DATA2 (BLOCK10)                          System data part 2 (reserved)
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W

Flash fuses:
FLASH_TPUW (BLOCK0)                                Configures flash waiting time after power-up; in u = 0 R/W (0x0)
                                                   nit of ms. If the value is less than 15; the waiti
                                                   ng time is the configurable value; Otherwise; the
                                                   waiting time is twice the configurable value
FORCE_SEND_RESUME (BLOCK0)                         Set this bit to force ROM code to send a resume co = False R/W (0b0)
                                                   mmand during SPI boot
FLASH_CAP (BLOCK1)                                 Flash capacity                                     = None R/W (0b000)
FLASH_TEMP (BLOCK1)                                Flash temperature                                  = None R/W (0b00)
FLASH_VENDOR (BLOCK1)                              Flash vendor                                       = None R/W (0b000)

Identity fuses:
DISABLE_WAFER_VERSION_MAJOR (BLOCK0)               Disables check of wafer version major              = False R/W (0b0)
DISABLE_BLK_VERSION_MAJOR (BLOCK0)                 Disables check of blk version major                = False R/W (0b0)
WAFER_VERSION_MINOR_LO (BLOCK1)                    WAFER_VERSION_MINOR least significant bits         = 4 R/W (0b100)
PKG_VERSION (BLOCK1)                               Package version                                    = 0 R/W (0b000)
BLK_VERSION_MINOR (BLOCK1)                         BLK_VERSION_MINOR                                  = 3 R/W (0b011)
WAFER_VERSION_MINOR_HI (BLOCK1)                    WAFER_VERSION_MINOR most significant bit           = False R/W (0b0)
WAFER_VERSION_MAJOR (BLOCK1)                       WAFER_VERSION_MAJOR                                = 0 R/W (0b00)
OPTIONAL_UNIQUE_ID (BLOCK2)                        Optional unique 128-bit ID
   = xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx R/W
BLK_VERSION_MAJOR (BLOCK2)                         BLK_VERSION_MAJOR of BLOCK2                        = With calibration R/W (0b01)
WAFER_VERSION_MINOR (BLOCK0)                       calc WAFER VERSION MINOR = WAFER_VERSION_MINOR_HI  = 4 R/W (0x4)
                                                   << 3 + WAFER_VERSION_MINOR_LO (read only)

Jtag fuses:
SOFT_DIS_JTAG (BLOCK0)                             Set these bits to disable JTAG in the soft way (od = 0 R/W (0b000)
                                                   d number 1 means disable ). JTAG can be enabled in
                                                    HMAC module
DIS_PAD_JTAG (BLOCK0)                              Set this bit to disable JTAG in the hard way. JTAG = False R/W (0b0)
                                                    is disabled permanently

Mac fuses:
MAC (BLOCK1)                                       MAC address
   = dc:da:0c:xx:xx:xx (OK) R/W
CUSTOM_MAC (BLOCK3)                                Custom MAC address
   = 00:00:00:00:00:00 (OK) R/W

Security fuses:
DIS_DOWNLOAD_ICACHE (BLOCK0)                       Set this bit to disable Icache in download mode (b = False R/W (0b0)
                                                   oot_mode[3:0] is 0; 1; 2; 3; 6; 7)
DIS_FORCE_DOWNLOAD (BLOCK0)                        Set this bit to disable the function that forces c = False R/W (0b0)
                                                   hip into download mode
DIS_DOWNLOAD_MANUAL_ENCRYPT (BLOCK0)               Set this bit to disable flash encryption when in d = False R/W (0b0)
                                                   ownload boot modes
SPI_BOOT_CRYPT_CNT (BLOCK0)                        Enables flash encryption when 1 or 3 bits are set  = Disable R/W (0b000)
                                                   and disables otherwise
SECURE_BOOT_KEY_REVOKE0 (BLOCK0)                   Revoke 1st secure boot key                         = False R/W (0b0)
SECURE_BOOT_KEY_REVOKE1 (BLOCK0)                   Revoke 2nd secure boot key                         = False R/W (0b0)
SECURE_BOOT_KEY_REVOKE2 (BLOCK0)                   Revoke 3rd secure boot key                         = False R/W (0b0)
KEY_PURPOSE_0 (BLOCK0)                             Purpose of Key0                                    = USER R/W (0x0)
KEY_PURPOSE_1 (BLOCK0)                             Purpose of Key1                                    = USER R/W (0x0)
KEY_PURPOSE_2 (BLOCK0)                             Purpose of Key2                                    = USER R/W (0x0)
KEY_PURPOSE_3 (BLOCK0)                             Purpose of Key3                                    = USER R/W (0x0)
KEY_PURPOSE_4 (BLOCK0)                             Purpose of Key4                                    = USER R/W (0x0)
KEY_PURPOSE_5 (BLOCK0)                             Purpose of Key5                                    = USER R/W (0x0)
SECURE_BOOT_EN (BLOCK0)                            Set this bit to enable secure boot                 = False R/W (0b0)
SECURE_BOOT_AGGRESSIVE_REVOKE (BLOCK0)             Set this bit to enable revoking aggressive secure  = False R/W (0b0)
                                                   boot
DIS_DOWNLOAD_MODE (BLOCK0)                         Set this bit to disable download mode (boot_mode[3 = False R/W (0b0)
                                                   :0] = 0; 1; 2; 3; 6; 7)
ENABLE_SECURITY_DOWNLOAD (BLOCK0)                  Set this bit to enable secure UART download mode   = False R/W (0b0)
SECURE_VERSION (BLOCK0)                            Secure version (used by ESP-IDF anti-rollback feat = 0 R/W (0x0000)
                                                   ure)
BLOCK_KEY0 (BLOCK4)
  Purpose: USER
               Key0 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_KEY1 (BLOCK5)
  Purpose: USER
               Key1 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_KEY2 (BLOCK6)
  Purpose: USER
               Key2 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_KEY3 (BLOCK7)
  Purpose: USER
               Key3 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_KEY4 (BLOCK8)
  Purpose: USER
               Key4 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W
BLOCK_KEY5 (BLOCK9)
  Purpose: USER
               Key5 or user data
   = 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 R/W

Spi Pad fuses:
SPI_PAD_CONFIG_CLK (BLOCK1)                        SPI PAD CLK                                        = 0 R/W (0b000000)
SPI_PAD_CONFIG_Q (BLOCK1)                          SPI PAD Q(D1)                                      = 0 R/W (0b000000)
SPI_PAD_CONFIG_D (BLOCK1)                          SPI PAD D(D0)                                      = 0 R/W (0b000000)
SPI_PAD_CONFIG_CS (BLOCK1)                         SPI PAD CS                                         = 0 R/W (0b000000)
SPI_PAD_CONFIG_HD (BLOCK1)                         SPI PAD HD(D3)                                     = 0 R/W (0b000000)
SPI_PAD_CONFIG_WP (BLOCK1)                         SPI PAD WP(D2)                                     = 0 R/W (0b000000)
SPI_PAD_CONFIG_DQS (BLOCK1)                        SPI PAD DQS                                        = 0 R/W (0b000000)
SPI_PAD_CONFIG_D4 (BLOCK1)                         SPI PAD D4                                         = 0 R/W (0b000000)
SPI_PAD_CONFIG_D5 (BLOCK1)                         SPI PAD D5                                         = 0 R/W (0b000000)
SPI_PAD_CONFIG_D6 (BLOCK1)                         SPI PAD D6                                         = 0 R/W (0b000000)
SPI_PAD_CONFIG_D7 (BLOCK1)                         SPI PAD D7                                         = 0 R/W (0b000000)

Usb fuses:
DIS_USB_JTAG (BLOCK0)                              Set this bit to disable function of usb switch to  = False R/W (0b0)
                                                   jtag in module of usb device
DIS_USB_SERIAL_JTAG (BLOCK0)                       USB-Serial-JTAG                                    = Enable R/W (0b0)
USB_EXCHG_PINS (BLOCK0)                            Set this bit to exchange USB D+ and D- pins        = False R/W (0b0)
DIS_USB_SERIAL_JTAG_ROM_PRINT (BLOCK0)             USB printing                                       = Enable R/W (0b0)
DIS_USB_SERIAL_JTAG_DOWNLOAD_MODE (BLOCK0)         Disable UART download mode through USB-Serial-JTAG = False R/W (0b0)

Vdd fuses:
VDD_SPI_AS_GPIO (BLOCK0)                           Set this bit to vdd spi pin function as gpio       = False R/W (0b0)

Wdt fuses:
WDT_DELAY_SEL (BLOCK0)                             RTC watchdog timeout threshold; in unit of slow cl = 40000 R/W (0b00)
                                                   ock cycle

セキュアブート

信頼されたコードのみを起動するために使う、セキュアブートについてです。

セキュアブートに関する話の前に、通常のブートについて記載します。 通常のブートは以下の流れで行われます。

  1. 電源投入(リセット)される
  2. ROMにある「1stステージブートローダ」が起動する
  3. 1stステージブートローダが、フラッシュにある「2ndステージブートローダ」を起動する2
  4. 2ndステージブートローダが、フラッシュにある「アプリケーション」を起動する

「1stステージブートローダ」は、工場出荷時にROMへ書かれたブートローダ。 文脈によっては「ROMコード」や「BootROM」と表現されることもあるようです。 このコードは開発者側で変更はできません。

「2ndステージブートローダ」は、フラッシュに書かれています。 文脈によっては単に「ブートローダ」とも。 これは開発者側で変更可能、というか最初は空なのでビルドして何か書き込まないと3アプリケーションが起動できません。

「アプリケーション」もフラッシュに書かれており、もちろん開発者側で変更可能。頑張って開発する部分です。

ここでセキュアブートを有効にすると、1stブートローダは2ndブートローダが正当なものか検証、2ndブートローダはアプリケーションが正当なものか検証してくれるようになります。 もし不当なものであれば起動しません。

ESP32-C3のセキュアブートでは “V2” (Version 2) が使われます。 この方式では、開発者側で2ndステージブートローダやアプリケーションのイメージに対し秘密鍵を用いて鍵長3072bitのRSA-PSSによる4署名を付与し、それに対する公開鍵を2ndステージブートローダとアプリケーションのイメージに含めたり、eFuseにSHA-256ダイジェストとして書いておいたりします。 そして、デバイス側で署名や公開鍵を基に検証、という流れです。

なお、やや古いデバイスでは “V1” (Version 1) のセキュアブートが使われていました。 この方式では共通鍵に基づく検証が行われており、秘密にしておくべき鍵がデバイス側のeFuseに保管されています。 とはいえ本来であれば外部から読み取れないようになっており、それなら別に……という感じですが、fault injectionにより読み取られてしまう可能性が指摘され、“V2”が追加されました。

Security Advisory concerning fault injection and eFuse protections (CVE-2019-17391) | Espressif Systems
https://www.espressif.com/en/news/Security_Advisory_Concerning_Fault_Injection_and_eFuse_Protections

“V2”ならデバイス側に秘密鍵は保管されません。 仮に第三者が公開鍵を知ったとしても、適切に作成された鍵であれば秘密鍵の推測は困難です。 fault injectionなどでセキュアブートが(一時的に)回避されることはあっても5、第三者が有効な署名を行うことは難しくセキュアブートが(永続的に、または攻撃を行っていない他のデバイスにおいても)機能しなくなる可能性は低いでしょう。

フラッシュ暗号化

ESP32-C3におけるフラッシュの内容は、一例として次のようになっています。

フラッシュの例 フラッシュの例

あくまで一例なので、アドレス(左側の数字)を含め必ずしもこの通りとは限りません。 これまで述べていない用語を簡単に紹介すると、“Partition Table”はその名の通りパーティションの情報が含まれる。 後述する「QEMUで予習」で少し出てきます。 NVSについてはこのあと紹介。 SPIFFSやFatFS、LittleFSは本記事では扱いませんがストレージの一種です。

このフラッシュは外部から簡単に読み出せます。 ここで「フラッシュ暗号化」を用いると 網掛け部分 の領域が暗号化され(逆にいえばそれ以外は暗号化されず)、外部から読み取られても復号されない限り内容を解析されにくくします。 一方デバイス内部からのアクセスについては、透過的な復号/暗号化がハードウェア処理によって実行されており、ソフトウェア側からは特に意識せずアクセス可能です。

暗号化の方式は、鍵長256bitのXTS-AES-1286が採用されています。 詳細については後述しますが、XTS-AESはストレージの暗号化に向いた方式として、BitLockerやdm-cryptなどでも使われているらしい。

フラッシュ暗号化を利用するには、次の手順を踏む必要があります。

  1. eFuseを設定する
    • AESの鍵などを書いておく
  2. 2ndブートローダやアプリケーションなどのイメージを作成する
  3. 暗号化してフラッシュに書き込む

eFuseに設定だけ行って、実際の暗号化処理はESP32-C3内部でやってもらうことも可能のようですが、ここでは扱いません。

また、鍵すらもESP32-C3の内部で作成することも可能7のようです。 これもここでは扱わないので詳細は省きますが……それって二度とフラッシュを書き換えできないのでは? 外部から書き込む際は暗号化されたイメージを書き込む必要8がある一方で、鍵は外部から読み出せないはずです。

これはOTA Updateを使うなら問題ありません。 フラッシュ暗号化は透過的に実行されるため、OTAにより平文のイメージを取得しても内部からは書き込めて、フラッシュには自動的に暗号化されて保存されます。 OTA Updateの際に平文イメージが第三者に見られると困るという場合は、たとえばHTTPSならちゃんとサーバ証明書を検証し中間者攻撃の対策をしたり、クライアント証明書も準備して正当なアクセスか検証したりするなどトランスポートレイヤのセキュリティに頼る方法があります。 または、それらやフラッシュ暗号化とは全く独立にAES-GCMで暗号化を行ったイメージをやり取りする方法もあるようです。 でも、ここでは試していないので詳細は分かりません。

フラッシュ暗号化で用いる鍵は各デバイス、1台1台ごとに変えることが推奨されます。 もし同じ鍵を使っていると、デバイス同士でフラッシュの内容が正確に比較できてしまいます。 独自にデバイス固有のシリアル番号や鍵などを書いていると、その差分から他のデバイスになりすますことも可能になるかも知れません。 下位モデルと上位モデルでハードウェアは共通、でもフラッシュ内のデータだけで動作を変えます、みたいな場合も上位モデルのフラッシュをコピーすれば下位モデルも変身!なども。 また、サイドチャネル攻撃などで鍵が漏洩する可能性があれば、攻撃を実施しやすいデバイス(自身の所有だったり、何らかの理由で攻撃対策がゆるいリビジョンなど)で鍵を取得すれば、その成果を他のデバイス(UART繋ぐくらいならできる他人のデバイスだったり、サイドチャネル攻撃が成功しないデバイス)でも活用できます。

ただ、外部で鍵を作る場合は鍵管理が面倒ですよね。 1つマスターキーを用意し、デバイス固有の情報を何らかの鍵導出関数に通して鍵を作ったら良いんでしょうか。 (このアイデアを真に受けて、実際に使って何らかの損害が出ても責任は取れません。) もっとも、Espressifをはじめ世間的にはOTA Updateを前提としているような、別に鍵は忘れても問題ない、というか忘れるべきなので管理しなくて良いという感じかも知れません。

おまけ: XTS-AES

そもそもXTS-AESとは? 馴染みがなかったので調べてみました。

(自分が気になった)XTS-AESの特徴としてはランダムアクセスが可能なこと。

AES-ECBでもランダムアクセスできるといえばそりゃそうでしょうが……安全性を考えると現実的ではありません。

AES-CBCのようなモードだと、あるデータにアクセス(復号)するにはその前のデータすべてを復号する必要があるのでランダムアクセスには不向き。 ストレージ全体ではなくこま切れにしてその中でAES-CBCを適用していけばマシになりそうですが、性能を上げようと思うと結局AES-ECBに近づいていきます。

AES-CTRなら?セクタ番号をNonce(とカウンタ)にできそうじゃないですか。 セクタ番号は一意なのでNonceの使い回しに起因する問題も無い? たしかにReadOnlyならそれで良いですが、普通のReadWriteならあるセクタ番号のデータを書き換えた時点で同じNonceから複数の暗号文を作成していることになります。 暗号文同士のXORを取ればそれらに対応する平文同士のXORと同じ結果が得られて平文が推測されやすくなる、またもしあるタイミングの平文が判明すれば、それに対する暗号文とXORを取ることでNonceから作成された乱数列が判明し、この乱数列と別のタイミングの暗号文でもXORを取ればそれに対する平文が判明してしまいます。

では、XTS-AESはどのように(安全な)ランダムアクセスを実現しているのでしょうか? 気になったので、ここでは自分の理解のためXTS-AESについてまとめてみます。

XTS-AESについてはIEEE 1619-2007で定められているとのことで、仕様が知りたければこれを読む……のですが、それはなかなか辛い。 別にイチから実装しようとか、何かエラッタを見つけて改善策を提案しようという気はありません。 そこで、CRYPTRECの「外部評価報告書」のうちXTSについて噛み砕いて書かれた下記の文書を読むことにします。

暗号利用モードXTSの安全性に関する調査及び評価
https://www.cryptrec.go.jp/exreport/cryptrec-ex-2801-2018.pdf

XTS モードの実装性能調査
https://www.cryptrec.go.jp/exreport/cryptrec-ex-2902-2019.pdf

まず、呼び方は “AES-XTS” ではなく “XTS-AES”9

そしてXTS-AESでは、2つの独立した鍵(鍵K1K_1および鍵K2K_2)使用します。 XTS-AES-128では鍵長128bitの鍵が2つ(合計鍵長256bit)、XTS-AES-256では鍵長256bitの鍵が2つ(合計鍵長512bit)必要です。 紛らわしいですね。 少し上で「(ESP32-C3のフラッシュ暗号化の方式は)鍵長256bitのXTS-AES-128」と述べましたが誤記ではありません10

さらにXTS-AESでは、Tweakの入力も必要です。 Tweakは128bitで、一般的にはセクタ番号などストレージデバイスのアドレスを基にしています。 ESP32-C3の場合は、フラッシュのアドレス11に基づいてTweakが設定されていました。 このTweakを鍵K2K_2を用いてAESで暗号化し、さらに(そのTweak内の)暗号化ブロックごとに値を調整12したものをマスクとします。 このマスクと平文をXORしたデータを別の鍵K1K_1を用いてAESで暗号化、さらにこの暗号文に対してもう一度マスクとXORを取ったものが最終的な暗号文です。 平文の長さがブロックサイズの整数倍でないなら(単純なパディングではなく)別の処理があるようですが、ESP32-C3においては関係無いのでこの辺は割愛します。

この方式ならランダムアクセスもいけますね。 安全性として第三者に暗号文から平文を予測されるかについては「現実的に安全」とされています。 ただ、メッセージ認証の仕組みは無いので改ざんされる可能性はあり。 セキュアブートと組み合わせましょう。と言ってもこれはこれでセキュアブートの検証が済んだあとに(SPI Flashの信号を横取りするなどして)書き換えられるリスクはありそうな……。

また、CPA(相関電力攻撃)などのサイドチャネル攻撃が行える可能性が指摘されており、実際ESP32系のチップ(ESP32-V3)でもCPAを適用しフラッシュ暗号化を解除できたという事例13や同じくESP32系のチップに対して電磁波解析を適用し(フラッシュ暗号化に注目しているわけではないものの)AESキーを抽出する投稿14もあります。

多少リスクはありますが、じゃあフラッシュ暗号化の意味ないじゃん、とはなりません。 攻撃者側はそれなりの手間がかかる一方で、開発者側でフラッシュ暗号化を有効にする手間は大してかかりません。 やりましょう。

NVS暗号化

フラッシュ暗号化では、NVS (Non-Volatile Storage) の領域は暗号化されません。 NVSは一種のKey-Value Store(namespaceも使う)で、Wi-FiやBLEの認証情報、その他その環境固有の情報を書きます。

……ってこれこそ暗号化すべき領域なのでは? フラッシュ暗号化の仕組みが適用されないのは謎15ですが、NVSを暗号化する方法は別で用意されているのでやりましょう。

フラッシュ暗号化ではハードウェアで処理を行っていましたが、NVS暗号化はソフトウェア16で処理を行います。 暗号化方式はフラッシュ暗号化と同様にXTS-AES。 ただし、鍵長から推測するとESP32-C3でもXTS-AES-256が使われるようです。 XTS-AESということで改ざん耐性はありません。 署名のような仕組みも無いので若干不安ですね。 とはいえ、CRCはあるので改ざんされ放題というわけでもありません。 (復号後に)狙ったCRCの値となるように、暗号文を変えるというのはなかなか難しいでしょう。

鍵の格納方法について、ESP32-C3では2パターンあります。 まず1つは、従来からある「フラッシュ暗号化ベース」の方法です。 あらかじめフラッシュにNVSキーパーティーションと呼ばれる領域を作成し、そこに鍵を書きます。 このままだと鍵が取られ放題なので、この領域はフラッシュ暗号化により保護します。

もう1つの鍵格納方法は、「HMACベース」です。 eFuseに256bit分のHMACキーを明示的に書いておいたり、またはデバイス側で作ってもらうように設定したりします。 デバイス側ではこのHMACキーをもとにNVS暗号化キー (512bit) を作って使います。 なんかこっちのほうが楽そうに見えますね。

でもこれだと外部からNVS領域にアクセスするとき、たとえば初期データを製品出荷時に書き込んでおくみたいなことは無理なのでは? 開発者側で操作できるのはあくまでHMACキーであって、NVS暗号化キーではないはずです。 ところが、NVS暗号化キーを作成する鍵導出関数17を見るとHMACキー以外は固定値となっており、HMACキーが分かればNVS暗号化キーが判明します。 そして、公式プログラミングガイドでも手元でHMACキーとともにNVS暗号化キーを作成し、その暗号化キーをもとにデータを事前に暗号化する手順が載っています18。 良かったですね。 作成されたNVS暗号化キーは実質256bit分のエントロピーしか無さそうですが、まぁ十分でしょう。

なお、NVSパーティションの名前は基本的に “nvs” となり、これは「デフォルトNVSパーティション」と呼ばれます。 これ以外にも独自の名前を設定することができ、それらは「カスタムNVSパーティション」と呼ばれる。 カスタムNVSパーティションを利用すれば、1つのフラッシュ内に複数のNVSパーティションを作ることも可能です。

デフォルトNVSパーティションを使う場合、NVS暗号化を使っていようがいまいが初期化の際には nvs_init_flash() を呼べば良いです。 内部でNVS暗号化の状態に応じた処理を切り替えて行ってくれます。 (ただし「NVS暗号化の状態」は実行時ではなくビルド時に判定されるので、バイナリを使い回す場合などは注意)

カスタムNVSパーティションを使う場合だと、NVS暗号化の状態に応じて呼び出す処理を変えたりする必要がありますが……ここでは使う予定はないので割愛。 自分が書いていないコード、たとえば何らかのライブラリが(デフォルトNVSパーティションの)NVSにアクセスすることはあっても、カスタムNVSパーティションにアクセスするようなライブラリはなかなか無いでしょう。

所感

これら「セキュアブート」「フラッシュ暗号化」「NVS暗号化」をやっておけば、おおむね安全なんじゃないでしょうか。 絶対安全ですとはなりませんが、下記のような既知の脆弱性に対するアドバイザリも確認し緩和策などの対策を取っておけば、もし破られてもそれは防ぎようがなかった、となるでしょう。情状酌量の余地あり。

Advisories | Espressif Systems
https://www.espressif.com/en/support/documents/advisories

Vulnerabilities - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/security/vulnerabilities.html

仮にfault injectionやサイドチャネル攻撃に対して完璧に対策していたとしても、それはそれとして実はあなた専用に偽のESP32-C3チップまたは金属シールドの下で細工されたモジュールが供給されていて、その中ではUARTを観測する機能が入っていました、eFuseに鍵を書くときのデータ全部バレています、とかだとどうしようもない。 これに対する策が求められるほど厳重な機器は開発しません。 さらに、ESP32-C3をはじめESP32系のモジュールは広く流通しており入手が簡単。 もしモジュールごと新しいものに交換された場合、そのモジュールで動くコードは管理しようがありません。 俺じゃないアイツがやった知らない済んだこと運動。

この記事で試すこと

この記事のタイトルが「ESP32-C3のセキュリティ機能をまとめる」だとこれで終わりだったんですが、そうもいきません。 以下からはこれまでに述べたセキュリティ機能を実際に試していきます。

試す内容を細かく書いておくと、

  • セキュリティ機能を有効にする
    • セキュアブートを有効に
    • フラッシュ暗号化も有効に
      • 外部で鍵を作成し、事前にイメージを暗号化して書き込む
    • NVS暗号化も使う
      • 「HMACベース」を使う
      • 外部で鍵を作成し、外部から初期値も書き込んでみる
  • イメージの更新はUART経由で行う
    • (より正確にはESP32-C3に内蔵されるUSB CDC経由で実施)
    • OTA Updateは使わない
  • アプリケーションはArduinoフレームワークで開発する
    • 開発環境はWindows
    • (でも今回の記事ではあまり登場しない、次回に乞うご期待)
  • ESP-IDFも使う
    • セキュリティ機能に対応した、独自の2ndステージブートローダをビルドしたり
    • 書き込みや暗号化などで利用する、ESP-IDFの付属ツールを使うために
    • これは主にLinux環境で動作させる(量産時の書き込み治具としてRaspberry Piを使うことを見越すため)

です。

セットアップ

一応Windows環境でもUSB CDCのドライバを入れたり、Linux環境へのudevルールを設定したりはこの辺を見て実施します。

Configure ESP32-C3 Built-in JTAG Interface - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
Configure USB Drivers
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/api-guides/jtag-debugging/configure-builtin-jtag.html#configure-usb-drivers

また、LinuxへのESP-IDFもこの辺を見て入れる。 bashrcなどにalias get_idf='. $HOME/esp/esp-idf/export.sh'と書いておき、開発する際は事前にget_idfを実施しておく。

Standard Toolchain Setup for Linux and macOS - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/get-started/linux-macos-setup.html

どれも特につまずくところはなく、素直にセットアップできました。

QEMUで予習

では2ndステージブートローダやアプリケーションをビルドして、各種セキュリティ機能を有効にしましょう。 ……といきたいところですが、有効にする過程でeFuseを触るなど取り返しのつかない操作が必要です。 もし失敗したらそのデバイスは文鎮と化します。

実はQEMUを利用して、ESP32-C3をエミュレーションする方法が用意されています。 すごいですね。 ただし、完全に実機どおりとはいきません。 実際、実機で動くイメージが動かなかった……という話は「おまけ」で後述しますが、そうは言ってもちょっと機能を試すならQEMU環境で十分です。

そこで、ここではQEMU環境を使ってセキュリティ機能を試し、予習をします。 セキュリティ機能を有効にする手順は下記ページを、

Security Features Enablement Workflows - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/security/security-features-enablement-workflows.html

またQEMU環境を使う手順は下記2つのページを参考にします。

QEMU Emulator - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/api-guides/tools/qemu.html

esp-toolchain-docs/qemu/esp32c3 at main · espressif/esp-toolchain-docs
https://github.com/espressif/esp-toolchain-docs/tree/279a2cb4f4a4a2b28acf6ddc23b02c7cba393e1a/qemu/esp32c3

さらにEspressifのブログには、まさにESP-C3のセキュリティ機能をQEMUで試すという投稿があります。

Trying out ESP32-C3’s security features using QEMU · Espressif Developer Portal
https://developer.espressif.com/blog/trying-out-esp32-c3s-security-features-using-qemu/

そのため、本記事でこれから述べることは二番煎じになってしまうのですが、上記Espressifの記事も参考にしてやっていきます。

プロジェクトの準備

QEMUのセットアップを済ませたうえで、プロジェクトを準備します。 今回ここで動かすのは、ESP-IDFのexampleプロジェクトのhello_worldをもとにしました。

NVSの読み書きもしたいので、ちょっと変えておきます。

diff(長いので折りたたみ)
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -1,3 +1,3 @@
 idf_component_register(SRCS "hello_world_main.c"
-                    PRIV_REQUIRES spi_flash
+                    PRIV_REQUIRES spi_flash nvs_flash
                     INCLUDE_DIRS "")
diff --git a/main/hello_world_main.c b/main/hello_world_main.c
--- a/main/hello_world_main.c
+++ b/main/hello_world_main.c
@@ -12,6 +12,8 @@
 #include "esp_chip_info.h"
 #include "esp_flash.h"
 #include "esp_system.h"
+#include "nvs_flash.h"
+#include "nvs.h"

 void app_main(void)
 {
@@ -42,6 +44,39 @@ void app_main(void)

     printf("Minimum free heap size: %" PRIu32 " bytes\n", esp_get_minimum_free_heap_size());

+    // NVS
+    // based on examples/storage/nvs_rw_value/main/nvs_value_example_main.c
+    esp_err_t err = nvs_flash_init();
+    ESP_ERROR_CHECK(err);
+    nvs_handle_t my_handle;
+    err = nvs_open("storage", NVS_READWRITE, &my_handle);
+    ESP_ERROR_CHECK(err);
+
+    printf("Reading restart counter from NVS ... ");
+    int32_t restart_counter = 0;
+    err = nvs_get_i32(my_handle, "restart_counter", &restart_counter);
+    switch (err) {
+        case ESP_OK:
+            printf("Done\n");
+            printf("Restart counter = %" PRIu32 "\n", restart_counter);
+            break;
+        case ESP_ERR_NVS_NOT_FOUND:
+            printf("The value is not initialized yet!\n");
+            break;
+        default :
+            printf("Error (%s) reading!\n", esp_err_to_name(err));
+    }
+
+    printf("Updating restart counter in NVS ... ");
+    restart_counter++;
+    err = nvs_set_i32(my_handle, "restart_counter", restart_counter);
+    printf((err != ESP_OK) ? "Failed!\n" : "Done\n");
+
+    printf("Committing updates in NVS ... ");
+    err = nvs_commit(my_handle);
+    printf((err != ESP_OK) ? "Failed!\n" : "Done\n");
+    nvs_close(my_handle);
+
     for (int i = 10; i >= 0; i--) {
         printf("Restarting in %d seconds...\n", i);
         vTaskDelay(1000 / portTICK_PERIOD_MS);

プロジェクトを適当にcloneしたりコピーしたりして、変更を加えたらidf.py set-target esp32c3しておきます。 さらにidf.py menuconfigして、お好みに応じてフラッシュサイズ19を変えておく。

フラッシュサイズの設定 フラッシュサイズの設定

パーティションの設定はデフォルトの “Single factory app, no OTA” をそのまま使います。 具体的な設定は下記のとおり。

# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  24K,
phy_init, data, phy,     0xf000,  4K,
factory,  app,  factory, 0x10000, 1M,

nvsはNVS、factoryはアプリケーション。 phy_initについては……よく分かりませんでした。RFキャリブレーションに使われる? 後述の結果を見ても暗号化の対象ではないようで、さらに初期値0xffから何も変化していません。 この検証ではRFを使うようなものはないのでこうなっている?これが実機だとどうなるのか、もしかして暗号化したほうが良いかは別の記事で検討します。

ちなみにオフセットについても述べておくと(後で使います)、2ndステージブートローダのオフセットは0x020、パーティションテーブルのオフセットは0x8000です。

とりあえずこのままビルドして、QEMUで動きを確認してみます。

$ idf.py build
$ idf.py qemu monitor
  :
--- esp-idf-monitor 1.5.0 on socket://localhost:5555 115200
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
  :
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0xc (RTC_SW_CPU_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
  :
entry 0x403cc71a
I (22884) boot: ESP-IDF v5.4 2nd stage bootloader
I (22885) boot: compile time Feb  5 2025 20:00:13
  :
I (22997) boot: Loaded app from partition at offset 0x10000
  :
I (23039) main_task: Calling app_main()
Hello world!
This is esp32c3 chip with 1 CPU core(s), WiFi/BLE, silicon revision v0.3, 4MB external flash
Minimum free heap size: 327700 bytes
Reading restart counter from NVS ... Done
Restart counter = 6
Updating restart counter in NVS ... Done
Committing updates in NVS ... Done
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...
Restarting in 7 seconds...
Restarting in 6 seconds...
Restarting in 5 seconds...
Restarting in 4 seconds...
Restarting in 3 seconds...
Restarting in 2 seconds...
Restarting in 1 seconds...
Restarting in 0 seconds...
Restarting now.
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
  :

ここにはコンソール(UART)の出力が表示されています。 1stステージブートローダの呼び出し、2ndステージブートローダの呼び出し、アプリケーションの呼び出し、アプリケーション内から再起動される様子が見えますね。

QEMU環境のフラッシュおよびeFuseの実体は、build/qemu_flash.binbuild/qemu_efuse.binというファイルに保存されています。 いまはフラッシュ暗号化などを有効にしていない状態のため、平文は見え放題です。

$ strings build/qemu_flash.bin | grep -i restart
Brestart_counter
restart_counter
restart_counter
restart_counter
restart_counter
:Hrestart_counter
restart_counter
Reading restart counter from NVS ...
restart_counter
Restart counter = %lu
Updating restart counter in NVS ...
Restarting in %d seconds...
Restarting now.

(フラッシュ暗号化を使っていても非暗号化領域に “Restarting in %d seconds…” などを埋め込むことは可能なため)これだけだと本当に「平文は見え放題」かは確認できない? しょうがないにゃあ・・Bzで見た画面も載せておきますので、雰囲気だけ感じてください。

暗号化が無効なフラッシュ 暗号化が無効なフラッシュ

これらのファイルはidf.py qemuを実行した際に作成されます。 たとえばqemu_flash.binを作成するときは、内部でビルドを実施してバイナリファイルを作成し、esptool.py merge_binにより作成、という流れです。 常にビルドとmerge_binが実施されることから、NVS領域はまっさらな状態に戻ります。

それだと困る、という場合はidf.py qemuに頼らずに直接QEMUのコマンドを叩けば良いです。 さらに、直接叩く場合はGPIOの入力を制御することも可能で、たとえば下記のように-global driver=esp32c3.gpio,property=strap_mode,value=0x02を指定すればダウンロードモードに入れます。

$ qemu-system-riscv32 -nographic -machine esp32c3 \
        -drive file=build/qemu_flash.bin,if=mtd,format=raw \
        -drive file=build/qemu_efuse.bin,if=none,format=raw,id=efuse \
        -global driver=nvram.esp32c3.efuse,property=drive,value=efuse \
        -global driver=esp32c3.gpio,property=strap_mode,value=0x02 \
        -serial tcp::5555,server,nowait

localhost:5555などでアクセスして、flash_idget_security_infoなどのコマンドも発行可能。

$ esptool.py --port socket://localhost:5555 flash_id
esptool.py v4.8.1
Serial port socket://localhost:5555/
  :
Detecting chip type... ESP32-C3
Chip is ESP32-C3 (QFN32) (revision v0.3)
Features: WiFi, BLE
Crystal is 40MHz
MAC: 00:00:00:00:00:00
Uploading stub...
Running stub...
Stub running...
Manufacturer: c8
Device: 4016
Detected flash size: 4MB
Hard resetting via RTS pin...

$ esptool.py --port socket://localhost:5555 get_security_info
esptool.py v4.8.1
Serial port socket://localhost:5555
  :
Security Information:
=====================
Flags: 0x00000000 (0b0)
Key Purposes: (0, 0, 0, 0, 0, 0, 12)
  BLOCK_KEY0 - USER/EMPTY
  BLOCK_KEY1 - USER/EMPTY
  BLOCK_KEY2 - USER/EMPTY
  BLOCK_KEY3 - USER/EMPTY
  BLOCK_KEY4 - USER/EMPTY
  BLOCK_KEY5 - USER/EMPTY
Chip ID: 5
API Version: 3
Secure Boot: Disabled
Flash Encryption: Disabled
SPI Boot Crypt Count (SPI_BOOT_CRYPT_CNT): 0x0
Hard resetting via RTS pin...

eFuseの書き込みも可能です(idf.py qemu efuse-burnでも書ける)。 フラッシュの読み書きも可能(直接build/qemu_flash.binを読み書きもできる)。

$ esptool.py --port socket://localhost:5555 read_flash 0 4194304 read_flash.bin
esptool.py v4.8.1
  :
4194304 (100 %)
Read 4194304 bytes at 0x00000000 in 71.9 seconds (466.9 kbit/s)...
Hard resetting via RTS pin...

# 内容は同じ
$ md5sum read_flash.bin build/qemu_flash.bin
1936fab152257b1629dc16fb9a5008f5  read_flash.bin
1936fab152257b1629dc16fb9a5008f5  build/qemu_flash.bin

セキュリティの有効化

準備ができたので、セキュリティ機能を有効化していきましょう。

まずは鍵の作成からです。 フラッシュ暗号化のXTS-AES-128で使う鍵は下記コマンドで作成できます。

$ espsecure.py generate_flash_encryption_key my_flash_encryption_key.bin

内部ではos.urandom()由来の乱数により鍵を作成しています。 opensslコマンドで作りたい場合は、次のコマンドでも代替可能です。

$ openssl rand -out my_flash_encryption_key.bin 32

セキュアブートのRSA 3072で使う秘密鍵は下記コマンドで作成します。

$ espsecure.py generate_signing_key --version 2 --scheme rsa3072 secure_boot_signing_key.pem
# or $ openssl genrsa -out secure_boot_signing_key.pem 3072

さらに秘密鍵から計算される公開鍵のダイジェストも必要です。

$ espsecure.py digest_sbv2_public_key --keyfile secure_boot_signing_key.pem --output digest.bin

(opensslコマンドでの作成方法は分かりませんでしたが……秘密鍵を作っているわけではないので問題ないでしょう)

NVS暗号化のXTS-AES-256で使う鍵も作ります。

$ python3 ${IDF_PATH}/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py \
        generate-key --key_protect_hmac --kp_hmac_keygen --kp_hmac_keyfile hmac_key.bin --keyfile nvs_encr_key.bin
Created encryption keys: ===>  /home/xxx...xxx/keys/nvs_encr_key.bin
$ ls keys/
hmac_key.bin  nvs_encr_key.bin

hmac_key.binは自分で作りたい、という場合はこういった方法も可能です。

$ mkdir -p keys/
$ openssl rand -out keys/hmac_key.bin 32
$ python3 ${IDF_PATH}/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py \
        generate-key --key_protect_hmac --kp_hmac_inputkey keys/hmac_key.bin --keyfile nvs_encr_key.bin
Created encryption keys: ===>  /home/xxx...xxx/keys/nvs_encr_key.bin
$ ls keys/
hmac_key.bin  nvs_encr_key.bin

これらの鍵をeFuseに書き込むのですが、その前にどの領域に何の鍵を書くか決めないといけません。 領域はBLOCK_KEY0からBLOCK_KEY5の範囲であればだいたいどこでも良いのですが、ここでは

BLOCK_KEY0 NVS暗号化用のHMACキー (HMAC_UP)
BLOCK_KEY1 フラッシュ暗号化の鍵 (XTS_AES_128_KEY)
BLOCK_KEY2 セキュアブート用の公開鍵ダイジェスト (SECURE_BOOT_DIGEST0)
BLOCK_KEY3~5 予備(将来的にSECURE_BOOT_DIGEST1,2も書きたくなったら使う)

としておきます。

置き場所も決まったので、ようやく書き込みに進みます。 (idf.py qemu efuse-burn-keyも使えますがそれは忘れて)ダウンロードモードで起動して、下記のように書いていきます。

$ espefuse.py --port socket://localhost:5555 --do-not-confirm \
        burn_key BLOCK_KEY0 keys/hmac_key.bin HMAC_UP \
        burn_key BLOCK_KEY1 my_flash_encryption_key.bin XTS_AES_128_KEY \
        burn_key BLOCK_KEY2 digest.bin SECURE_BOOT_DIGEST0

ついでに関連するeFuse21も書いておく。 後述するmenuconfigの設定によっては起動後22にeFuseを内部から書いてくれることもあるようですが、一応外部から明示的に書きます。

$ espefuse.py --port socket://localhost:5555 --do-not-confirm \
        burn_efuse SPI_BOOT_CRYPT_CNT 7 \
        burn_efuse SECURE_BOOT_EN       \
        burn_efuse DIS_DOWNLOAD_ICACHE  \
        burn_efuse DIS_DIRECT_BOOT      \
        burn_efuse DIS_USB_JTAG         \
        burn_efuse DIS_PAD_JTAG         \
        burn_efuse DIS_DOWNLOAD_MANUAL_ENCRYPT \
        burn_efuse SOFT_DIS_JTAG 7

次に、idf.py menuconfigを実行してconfigを設定します。 特に関係するのはこの辺り、

  • CONFIG_SECURE_FLASH_ENC_ENABLED=y
    • “Security features” > “Enable flash encryption on boot (READ DOCS FIRST)”
  • CONFIG_SECURE_FLASH_ENCRYPTION_MODE_RELEASE=y
    • “Security features” > “Enable usage mode” > “Release”
  • CONFIG_SECURE_ENABLE_SECURE_ROM_DL_MODE=y
    • “Security features” > “UART ROM download mode” > “UART ROM download mode (Permanently switch to Secure mode (recommended))”
    • “Secure mode” になると、(外部から)eFuseの読み書きが不可、フラッシュの読み取り不可など制限がかかる
  • CONFIG_SECURE_BOOT=y
    • “Security features” > “Enable hardware Secure Boot in bootloader (READ DOCS FIRST)”
  • CONFIG_SECURE_BOOT_V2_ENABLED=y
    • “Security features” > “Select secure boot version” > “Enable Secure Boot version 2”
  • CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES
    • “Security features” > “Sign binaries during build”
    • これはOffにする
  • CONFIG_NVS_ENCRYPTION=y
    • “Component config” > “NVS” > “Enable NVS encryption”
  • CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC=y
    • “Component config” > “NVS Security Provider” > “Using HMAC peripheral”
  • CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID=0
    • “Component config” > “NVS Security Provider” > “eFuse key ID storing the HMAC key”
    • BLOCK_KEY0にHMACキーを書いたので0を設定する

\ “Security features”の設定

ほかは、下記ドキュメントを見ながらお好みで設定していきます。

Project Configuration - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/api-reference/kconfig.html

アプリのデバッグに関する設定もありますが、とりあえず見なかったことにします。 おそらく製品をリリースするときには無効にすることになるでしょう。 また、この辺くらいも設定することになるかも知れません。

  • CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y
    • “Bootloader config” > “Log” > “No output”
    • フラッシュ暗号化とセキュアブートを有効にしたことで、2ndステージブートローダのサイズが大きくなる
    • デフォルトのパーティション設定では収まらずビルドに失敗するので、ログ出力を省いてサイズを小さくする
    • 参考: Bootloader#Bootloader Size - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
  • CONFIG_BOOT_ROM_LOG_ALWAYS_OFF=y
    • “Boot ROM Behavior” > “Permanently change Boot ROM output” > “Permanently disable logging”
    • 1stステージブートローダのログも出力しない(ようにeFuseUART_PRINT_CONTROLを設定してくれるはず)
    • 単純にUARTから出力して欲しくない場合があるので使うかも
    • (この記事の検証においては設定しない)
  • CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y
    • “Component config” > “ESP System Settings” > “Panic handler behaviour” > “Silent reboot”
    • パニック時にログを書かずにリブートしてもらう
    • (この記事の検証においては設定しない)
  • CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
    • “Component config” > “ESP System Settings” > “Channel for console output” > “USB Serial/JTAG Controller”
    • コンソールにUART0ではなく、USBシリアルを使ってもらう
    • (この記事の検証においては設定しない)
  • CONFIG_LOG_DEFAULT_LEVEL_NONE=y
    • “Component config” > “Log” > “Log Level” > “Default log verbosity” > “No output”
    • コンソールにいかなるログも出なくなる
    • (この記事の検証においては設定しない)

実行

これで設定は完了です。 あとはビルドして、フラッシュ用のファイルを作って実行していきます。

# ビルド
$ idf.py build

# セキュアブート用の署名
$ espsecure.py sign_data --version 2 --keyfile secure_boot_signing_key.pem --output bootloader-signed.bin build/bootloader/bootloader.bin
$ espsecure.py sign_data --version 2 --keyfile secure_boot_signing_key.pem --output hello_world-signed.bin build/hello_world.bin

# 暗号化
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x0 --output bootloader-enc.bin bootloader-signed.bin
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x8000 --output partition-table-enc.bin build/partition_table/partition-table.bin
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x10000 --output hello_world-enc.bin hello_world-signed.bin

# フラッシュ用のファイル
$ esptool.py --chip=esp32c3 merge_bin --output=flash-enc.bin --fill-flash-size=4MB --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 bootloader-enc.bin 0x10000 hello_world-enc.bin 0x8000 partition-table-enc.bin

そして実行。

$ qemu-system-riscv32 -nographic -machine esp32c3 \
        -drive file=flash-enc.bin,if=mtd,format=raw \
        -drive file=build/qemu_efuse.bin,if=none,format=raw,id=efuse \
        -global driver=nvram.esp32c3.efuse,property=drive,value=efuse \
        -serial tcp::5555,server,nowait

nc localhost 5555などでコンソールを見ると、こんな感じです。

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0xc (RTC_SW_CPU_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
Valid secure boot key blocks: 0
secure boot verification succeeded
load:0x3fcd5990,len:0xc68
load:0x403cc710,len:0x958
load:0x403ce710,len:0x4558
entry 0x403cc710
I (2756) cpu_start: Unicore app
I (2769) cpu_start: Pro cpu start user code
I (2769) cpu_start: cpu freq: 160000000 Hz
I (2769) app_init: Application information:
I (2769) app_init: Project name:     hello_world
I (2770) app_init: App version:      1
I (2770) app_init: Compile time:     Feb  7 2025 17:47:31
I (2770) app_init: ELF file SHA256:  573f8e89a...
I (2770) app_init: ESP-IDF:          v5.4
I (2770) efuse_init: Min chip rev:     v0.3
I (2770) efuse_init: Max chip rev:     v1.99
I (2771) efuse_init: Chip rev:         v0.3
I (2771) heap_init: Initializing. RAM available for dynamic allocation:
I (2771) heap_init: At 3FC8EA20 len 000315E0 (197 KiB): RAM
I (2771) heap_init: At 3FCC0000 len 0001C710 (113 KiB): Retention RAM
I (2772) heap_init: At 3FCDC710 len 00002950 (10 KiB): Retention RAM
I (2772) heap_init: At 5000021C len 00001DCC (7 KiB): RTCRAM
I (2780) spi_flash: detected chip: gd
I (2781) spi_flash: flash io: dio
I (2781) flash_encrypt: Flash encryption mode is RELEASE
I (2782) sleep_gpio: Configure to isolate all GPIO pins in sleep state
I (2783) sleep_gpio: Enable automatic switching of GPIO sleep configuration
I (2783) nvs_sec_provider: NVS Encryption - Registering HMAC-based scheme...
I (2789) main_task: Started on CPU0
I (2789) main_task: Calling app_main()
Hello world!
This is esp32c3 chip with 1 CPU core(s), WiFi/BLE, silicon revision v0.3, 4MB external flash
Minimum free heap size: 325744 bytes
I (2799) nvs: NVS partition "nvs" is encrypted.
Reading restart counter from NVS ... Done
Restart counter = 3
Updating restart counter in NVS ... Done
Committing updates in NVS ... Done
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...

1stステージブートローダからのsecure boot verification succeededや、 フラッシュ暗号化やNVS暗号化に関するコンポーネントからのflash_encrypt: Flash encryption mode is RELEASE23とかnvs: NVS partition "nvs" is encrypted.という表示も見えますね。 2ndステージブートローダで「アプリケーションの署名を検証した」みたいなログはおそらく”No output”のため出ていませんが……この辺は後ほど検証します。

ダウンロードモードにしてget_security_infoを叩くとこんな感じで意図通りです。

$ esptool.py --port socket://localhost:5555 get_security_info
esptool.py v4.8.1
  :
Chip is ESP32-C3 in Secure Download Mode
WARNING: Stub loader is not supported in Secure Download Mode, setting --no-stub
Enabling default SPI flash mode...

Security Information:
=====================
Flags: 0x000004f5 (0b10011110101)
Key Purposes: (8, 4, 9, 0, 0, 0, 12)
  BLOCK_KEY0 - HMAC_UP
  BLOCK_KEY1 - XTS_AES_128_KEY
  BLOCK_KEY2 - SECURE_BOOT_DIGEST0
  BLOCK_KEY3 - USER/EMPTY
  BLOCK_KEY4 - USER/EMPTY
  BLOCK_KEY5 - USER/EMPTY
Chip ID: 5
API Version: 3
Secure Boot: Enabled
Secure Boot Key Revocation Status:

        Secure Boot Key1 is Revoked

        Secure Boot Key2 is Revoked

Flash Encryption: Enabled
SPI Boot Crypt Count (SPI_BOOT_CRYPT_CNT): 0x7
Icache in UART download mode: Disabled
JTAG: Permanently Disabled
Hard resetting via RTS pin...

flash_idは見れなくなる。

$ esptool.py --port socket://localhost:5555 flash_id
esptool.py v4.8.1
Serial port socket://localhost:5555
Note: It's not possible to reset the chip over a TCP socket. Automatic resetting to bootloader has been disabled, reset the chip manually.
Connecting...
Device PID identification is only supported on COM and /dev/ serial ports.

Detecting chip type... ESP32-C3
Chip is ESP32-C3 in Secure Download Mode
  :
esptool.util.UnsupportedCommandError: This command (0xa) is not supported in Secure Download Mode

eFuseの情報は外部からは読み出し不可24、(書いていないはずの領域でも)書き込みも不可です。

# 読み出し不可
$ espefuse.py --port socket://localhost:5555 summary
espefuse.py v4.8.1
  :
A fatal error occurred: Secure Download Mode is enabled. The tool can not read eFuses.

# 書き込み不可
$ espefuse.py --port socket://localhost:5555 burn_efuse SECURE_BOOT_AGGRESSIVE_REVOKE
espefuse.py v4.8.1
  :
A fatal error occurred: Secure Download Mode is enabled. The tool can not read eFuses.

# 未使用BLOCK_KEYも書き込み不可
$ espefuse.py --port socket://localhost:5555 burn_key BLOCK_KEY3 digest.bin SECURE_BOOT_DIGEST1
espefuse.py v4.8.1
  :
A fatal error occurred: Secure Download Mode is enabled. The tool can not read eFuses.

# --force-write-always オプションでも不可
$ espefuse.py --port socket://localhost:5555 burn_key --force-write-always BLOCK_KEY3 digest.bin SECURE_BOOT_DIGEST1
espefuse.py v4.8.1
  :
A fatal error occurred: Secure Download Mode is enabled. The tool can not read eFuses.

# 変化なし
$ esptool.py --port socket://localhost:5555 get_security_info
esptool.py v4.8.1
  :
Security Information:
=====================
Flags: 0x000004f5 (0b10011110101)
Key Purposes: (8, 4, 9, 0, 0, 0, 12)
  BLOCK_KEY0 - HMAC_UP
  BLOCK_KEY1 - XTS_AES_128_KEY
  BLOCK_KEY2 - SECURE_BOOT_DIGEST0
  BLOCK_KEY3 - USER/EMPTY
  BLOCK_KEY4 - USER/EMPTY
  BLOCK_KEY5 - USER/EMPTY
Chip ID: 5
API Version: 3
Secure Boot: Enabled
Secure Boot Key Revocation Status:

        Secure Boot Key1 is Revoked

        Secure Boot Key2 is Revoked

Flash Encryption: Enabled
SPI Boot Crypt Count (SPI_BOOT_CRYPT_CNT): 0x7
Icache in UART download mode: Disabled
JTAG: Permanently Disabled
Hard resetting via RTS pin...

もうフラッシュも読み取れません。(後述しますが、書き込みについては可能)

$ esptool.py --port socket://localhost:5555 read_flash 0x0 4194304 read_flash_enc.bin
esptool.py v4.8.1
  :
esptool.util.UnsupportedCommandError: This command (0xe) is not supported in Secure Download Mode

ここはQEMU環境なので実体のファイルが見えます25。 ただし、NVSの領域も含めて暗号化されているので、見えたところで第三者には(復号されなければ)意味が分かりません。

$ strings flash-enc.bin | grep -i restart | wc -l
0

一応またBzの画面も。ビットマップを見ると何らかのパーティションがあることは感じ取れつつも、具体的な内容は構造が分からず意味は読み取れそうにないですね。

暗号化が有効なフラッシュ 暗号化が有効なフラッシュ

なお、鍵があればespsecure.py decrypt_flash_dataにより復号できるようですが、使うことはなさそうなので割愛。

アプリ書き換え

アプリケーションを書き換えて、再起動までの時間を20秒にします。

diff --git a/main/hello_world_main.c b/main/hello_world_main.c
--- a/main/hello_world_main.c
+++ b/main/hello_world_main.c
@@ -77,7 +77,7 @@ void app_main(void)
     printf((err != ESP_OK) ? "Failed!\n" : "Done\n");
     nvs_close(my_handle);

-    for (int i = 10; i >= 0; i--) {
+    for (int i = 20; i >= 0; i--) {
         printf("Restarting in %d seconds...\n", i);
         vTaskDelay(1000 / portTICK_PERIOD_MS);
     }

idf.py buildでビルドし、新たなbuild/hello_world.binを作成し、フラッシュをこのコードに書き換えます。

正当編 その1

まず、正当な鍵を使って、正当な操作で書き換えます。

build/hello_world.binに対して再度署名と暗号化を行い、とりあえずいまは(変更していないブートローダなどとともに)更新用フラッシュ(flash-20-enc.bin)を作ってみます。

# 署名&暗号化
$ espsecure.py sign_data --version 2 --keyfile secure_boot_signing_key.pem --output hello_world-20-signed.bin build/hello_world.bin
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x10000 --output hello_world-20-enc.bin hello_world-20-signed.bin

# 更新用フラッシュの作成
$ esptool.py --chip=esp32c3 merge_bin --output=flash-20-enc.bin --fill-flash-size=4MB --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 bootloader-enc.bin 0x10000 hello_world-20-enc.bin 0x8000 partition-table-enc.bin

QEMUに渡すフラッシュ(flash-enc.bin)のままでダウンロードモードで起動します。

$ qemu-system-riscv32 -nographic -machine esp32c3 \
        -drive file=flash-enc.bin,if=mtd,format=raw \
        -drive file=build/qemu_efuse.bin,if=none,format=raw,id=efuse \
        -global driver=nvram.esp32c3.efuse,property=drive,value=efuse \
        -global driver=esp32c3.gpio,property=strap_mode,value=0x02 \
        -serial tcp::5555,server,nowait

当然、QEMUのフラッシュと更新用フラッシュは別ものです。

$ md5sum flash-enc.bin flash-20-enc.bin
9bf116f1a53238a13fc0cf8a14e723d1  flash-enc.bin
7ecee37e1d8ffa0a532cb411d5c5e1f6  flash-20-enc.bin

esptool.py write_flashを使ってフラッシュの全領域を更新用フラッシュで上書きします。

# 文鎮になる可能性があるから注意!と言われる
$ esptool.py --port socket://localhost:5555 write_flash \
        --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 flash-20-enc.bin
esptool.py v4.8.1
  :
A fatal error occurred: WARNING: Detected flash encryption and secure download mode enabled.
Flashing plaintext binary may brick your device! Use --force to override the warning.

# --force が必要
$ esptool.py --port socket://localhost:5555 write_flash \
        --force --flash_mode dio --flash_freq 80m --flash_size 4MB 0x0 flash-20-enc.bin
esptool.py v4.8.1
  :
Enabling default SPI flash mode...
Configuring flash size...
Flash will be erased from 0x00000000 to 0x003fffff...
Erasing flash...
WARNING: Security features enabled, so not changing any flash settings.
Took 0.05s to erase flash block
Wrote 4194304 bytes at 0x00000000 in 535.8 seconds (62.6 kbit/s)...

Leaving...
Hard resetting via RTS pin...

write_flashのあとは、QEMUのフラッシュの内容が更新用フラッシュと同じになりました。

$ md5sum flash-enc.bin flash-20-enc.bin
7ecee37e1d8ffa0a532cb411d5c5e1f6  flash-enc.bin
7ecee37e1d8ffa0a532cb411d5c5e1f6  flash-20-enc.bin

これで、通常どおり起動すると……ちゃんと更新されています。

secure boot verification succeeded
  :
I (17491) flash_encrypt: Flash encryption mode is RELEASE
  :
Hello world!
  :
Restarting in 20 seconds...
Restarting in 19 seconds...
Restarting in 18 seconds...
Restarting in 17 seconds...
Restarting in 16 seconds...
Restarting in 15 seconds...
Restarting in 14 seconds...
Restarting in 13 seconds...
Restarting in 12 seconds...
Restarting in 11 seconds...
Restarting in 10 seconds...
Restarting in 9 seconds...

正当編 その2

「正当編 その1」ではmerge_binにより新たなフラッシュのデータを作成していました。 ただ、これだとNVS領域まで消えてしまいます。 アプリケーション部分だけを変更したのであれば、その部分だけの書き換えで問題ありません。 これだとNVS領域を残せます。

ビルドしてアプリケーションに署名、暗号化を施したhello_world-20-enc.binを作成したら、次のコマンドでフラッシュをそこだけ書き換えます。

$ esptool.py --port socket://localhost:5555 write_flash \
        --force --flash_mode dio --flash_freq 80m --flash_size 4MB 0x10000 hello_world-20-enc.bin
esptool.py v4.8.1
  :
Flash will be erased from 0x00010000 to 0x00050fff...
Erasing flash...
WARNING: Security features enabled, so not changing any flash settings.
Took 0.05s to erase flash block
Wrote 266240 bytes at 0x00010000 in 33.9 seconds (62.8 kbit/s)...

Leaving...
Hard resetting via RTS pin...

もちろんこれでも想定通り更新されています。

署名ミス編

署名についてミスしたときにどうなるか?確認します。

まずは、うっかり違う鍵で署名してしまった編。

# 無関係な署名用鍵 "another_secure_boot_signing_key.pem" を作成
$ openssl genrsa -out another_secure_boot_signing_key.pem 3072

# 無関係な鍵で署名
$ espsecure.py sign_data --version 2 --keyfile another_secure_boot_signing_key.pem --output hello_world-20-another-signed.bin build/hello_world.bin

# 暗号化(これは正当な暗号鍵)
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x10000 --output hello_world-20-another-enc.bin hello_world-20-another-signed.bin

write_flashして、出力を確認するとこうなります。

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
Valid secure boot key blocks: 0
secure boot verification succeeded
load:0x3fcd5990,len:0xc68
load:0x403cc710,len:0x958
load:0x403ce710,len:0x4558
entry 0x403cc710
Sig block 0 signed with untrusted key
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
  :
(以降再起動して同じメッセージが繰り返される)

1stステージブートローダによる2ndステージブートローダの検証は成功したものの、その先の2ndステージブートローダによるアプリケーションの検証ではSig block 0 signed with untrusted keyというメッセージが出たあと、再起動ループに陥ります。 良いですね。

次は、うっかり署名自体を忘れてしまった編。

# 無署名のファイルを暗号化
$ espsecure.py encrypt_flash_data --aes_xts --keyfile my_flash_encryption_key.bin --address 0x10000 --output hello_world-20-nosign-enc.bin build/hello_world.bin

またwrite_flashして、出力を確認するとこうなります。

ESP-ROM:esp32c3-api1-20210207
  :
entry 0x403cc710
Sig block 0 signed with untrusted key

先ほどと同じですね。

さらに、うっかり?改ざんした場合も試します。 正しく署名済みのファイルに対して、アプリケーションの名前を変えます。

# 正しく署名済みのファイル
$ file hello_world-20-signed.bin
hello_world-20-signed.bin: ESP-IDF application image for ESP32-C3, project name: "hello_world", version 1, compiled on Feb  7 2025 19:55:24, IDF version: v5.4, entry address: 0x403802EA

# 名前を変える
$ bbe -e 's/hello_world\x00/howdy_world\x00/' hello_world-20-signed.bin > hello_world-20-signed-howdy.bin
$ file hello_world-20-signed-howdy.bin
hello_world-20-signed-howdy.bin: ESP-IDF application image for ESP32-C3, project name: "howdy_world", version 1, compiled on Feb  7 2025 19:55:24, IDF version: v5.4, entry address: 0x403802EA
$ diff <(hexdump -C hello_world-20-signed.bin) <(hexdump -C hello_world-20-signed-howdy.bin)
6c6
< 00000050  68 65 6c 6c 6f 5f 77 6f  72 6c 64 00 00 00 00 00  |hello_world.....|
---
> 00000050  68 6f 77 64 79 5f 77 6f  72 6c 64 00 00 00 00 00  |howdy_world.....|

改変後のファイルを暗号化し、write_flashで書き込みました。 これで起動して出力を確認するとこうなります。

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
Valid secure boot key blocks: 0
secure boot verification succeeded
load:0x3fcd5990,len:0xc68
load:0x403cc710,len:0x958
load:0x403ce710,len:0x4558
entry 0x403cc710
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
  :
(以降再起動して同じメッセージが繰り返される)

untrusted keyのメッセージも出ず、再起動ループに陥ります。 まぁ鍵をいじっているわけではないので……。

ちなみに、このあと本来の正しいアプリケーションイメージを書いて正常動作に戻しました26

暗号化ミス編

暗号化の手順を間違えたらどうなる?それも確認します。

まずは、うっかり違う鍵で暗号化してしまった編。

# 無関係な鍵を作成
$ openssl rand -out another_encryption_key.bin 32

# その鍵で暗号化
$ espsecure.py encrypt_flash_data --aes_xts --keyfile another_encryption_key.bin --address 0x10000 --output hello_world-20-anotherenckey-enc.bin hello_world-20-signed.bin

これでwrite_flashして、出力を確認するとこうなります。

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
Valid secure boot key blocks: 0
secure boot verification succeeded
load:0x3fcd5990,len:0xc68
load:0x403cc710,len:0x958
load:0x403ce710,len:0x4558
entry 0x403cc710
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
  :
(以降再起動して同じメッセージが繰り返される)

まともに起動せず、ですね。

次に、うっかり暗号化なしで書いてしまった編。 hello_world-20-signed.binをそのまま書き込みます。 出力は下記のとおり。

ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
Valid secure boot key blocks: 0
secure boot verification succeeded
load:0x3fcd5990,len:0xc68
load:0x403cc710,len:0x958
load:0x403ce710,len:0x4558
entry 0x403cc710
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
  :
(以降再起動して同じメッセージが繰り返される)

これもまともに起動しません。

ちなみにこの状況でも、正当なイメージを書けばちゃんと正常動作に戻ります。 良かったですね。

NVS書き換え

NVSの領域を外部から書き換えます。 「書き換え」といっても現在のデータを操作する27のではなく、新たにデータを作って差し替える感じです。

下記のユーティリティを使えば、CSVファイルからNVS用のデータを作ってくれます28。 暗号化や復号も可能です。

NVS Partition Generator Utility - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation
https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32c3/api-reference/storage/nvs_partition_gen.html

ここでは、storageというネームスペースの、restart_counterというkeyに、i32型で12345というvalueを書こうとしてこんなCSVファイルを用意しました。

key,type,encoding,value
storage,namespace,,
restart_counter,data,i32,12345

これをnvs.csvとし、NVS領域の大きさが24KiByte (0x6000) の場合、次のコマンドでデータnvs.binを作ります。

$ python3 ${IDF_PATH}/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py \
        encrypt --inputkey ./keys/nvs_encr_key.bin nvs.csv nvs.bin 0x6000

Creating NVS binary with version: V2 - Multipage Blob Support Enabled

Created NVS binary: ===> /home/xxx...xxx/nvs.bin

そしてwrite_flashする。

$ esptool.py --port socket://localhost:5555 write_flash \
        --force --flash_mode dio --flash_freq 80m --flash_size 4MB 0x9000 nvs.bin

再起動して出力を確認すると、こんな感じになりました。

I (166) nvs: NVS partition "nvs" is encrypted.
Reading restart counter from NVS ... Done
Restart counter = 12345
Updating restart counter in NVS ... Done
Committing updates in NVS ... Done

ちゃんと書き換えられていますね。

ところで、これまでに述べたとおりNVS暗号化はXTS-AES-256によって実施されます。 XTS-AESは改ざんの耐性はなく、NVS暗号化では署名検証の仕組みもありません。 一応CRCはありますが……不安です。 そこで、現実的に改ざんが可能か試してみます。

ここではKeyのValueがStoreされる位置を何らかの方法で特定したものとします。 たとえば、read_flash以外の方法でフラッシュを読まれて(特に今回の場合は)再起動ごとに値が変化している場所が判明した、というような感じです。 今回の検証ではもっと楽に、まず暗号化していないNVSのデータを作成し12345(リトルエンディアンに直すと0x39300000)が書かれている位置を特定しました。

$ python3 ${IDF_PATH}/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py \
        generate nvs.csv plain.bin 0x6000

$ hexdump -C plain.bin
  :
00000040  00 01 01 ff 09 a9 50 07  73 74 6f 72 61 67 65 00  |......P.storage.|
00000050  00 00 00 00 00 00 00 00  01 ff ff ff ff ff ff ff  |................|
00000060  01 14 01 ff 1b 45 b4 bc  72 65 73 74 61 72 74 5f  |.....E..restart_|
00000070  63 6f 75 6e 74 65 72 00  39 30 00 00 ff ff ff ff  |counter.90......| # この辺: 39 30 00 00
  :

これを基に、暗号化が有効なNVSのデータを改変します。 復号後の値を狙ったものにするのは難しそうですが、とりあえず適当なデータを書きました。

$ cp nvs.bin nvs-mod.bin
$ printf '\xff' | dd bs=1 count=1 seek=120 conv=notrunc of=nvs-mod.bin
$ diff <(hexdump -C nvs.bin) <(hexdump -C nvs-mod.bin)
8c8
< 00000070  c5 07 dd 10 0e 4b 64 48  61 3e fa aa 49 5f a7 fe  |.....KdHa>..I_..| # 61
---
> 00000070  c5 07 dd 10 0e 4b 64 48  ff 3e fa aa 49 5f a7 fe  |.....KdH.>..I_..| # ff

そして、書き込みをして出力を見ますが……何も存在しないことになっていました。

I (171) nvs: NVS partition "nvs" is encrypted.
Reading restart counter from NVS ... The value is not initialized yet!

おそらくCRCも変えないとダメでしょう。 元データの値を12346にして作成したデータ(非暗号化)と比較すると、数値のほかに4バイト変更されておりこれがCRCに関連していると思われます。

$ diff <(hexdump -C plain.bin) <(hexdump -C plain-12346.bin)
7,8c7,8
< 00000060  01 14 01 ff 1b 45 b4 bc  72 65 73 74 61 72 74 5f  |.....E..restart_| # 1b 45 b4 bc
< 00000070  63 6f 75 6e 74 65 72 00  39 30 00 00 ff ff ff ff  |counter.90......|
---
> 00000060  01 14 01 ff f8 42 3b 32  72 65 73 74 61 72 74 5f  |.....B;2restart_| # f8 42 3b 32
> 00000070  63 6f 75 6e 74 65 72 00  3a 30 00 00 ff ff ff ff  |counter.:0......|

実際にユーティリティ側でバイナリデータを作成する際の処理を見ても、確かに4バイトのCRCが書かれているのでそれっぽいです。 復号後に狙ったCRCの値になるように、暗号化されたデータを調整するというのはなかなか難しそうですね。

ということで改ざんは簡単ではないと思います。 まだ不安であれば、独自に署名検証の仕組みを入れたり29、ReadOnlyで良いならアプリケーション部分に埋め込んでセキュアブートの仕組みで検証してもらう……とかでしょうか。 (このアイデアを真に受けて、実際に使って何らかの損害が出ても責任は取れません。再掲。)

おまけ: 動かない編

「アプリケーションはArduinoフレームワークで開発する」でしたね。 では次は、これをQEMU環境で動かして……といきたいところでしたが、どうやっても動かせませんでした。

2ndステージブートローダとアプリケーションが、ESP-IDFに由来するかarduino-esp32に由来するかで組み合わせを決めて、動作を確認するとこんな結果になりました。

2ndステージブートローダアプリケーション→ 動作 →QEMU実機
ESP-IDFESP-IDFOK-
ESP-IDFarduino-esp32NGOK
arduino-esp32ESP-IDFOK-
arduino-esp32arduino-esp32NGOK

QEMUではNGでも、実機ではOKだったり。 アプリケーションがarduino-esp32由来だと問題があるように見える。 QEMUでNG、の一例としては下記メッセージが出て再起動ループに陥ります。

ELF file SHA256: 95973669576cadda

E (1583) esp_core_dump_flash: Core dump flash config is corrupted! CRC=0x7bd5c66f instead of 0x0
Rebooting...
ESP-ROM:esp32c3-api1-20210207
Build:Feb  7 2021
rst:0xc (RTC_SW_CPU_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fcd5810,len:0x438
load:0x403cc710,len:0x90c
load:0x403ce710,len:0x2624
entry 0x403cc710

assert failed: do_core_init startup.c:328 (flash_ret == ESP_OK)
Core  0 register dump:
MEPC    : 0x4038242a  RA      : 0x40386cf2  SP      : 0x3fcde190  GP      : 0x3fc8e200

これはWindows環境でESP-IDF v5.3を使っていたときに遭遇しました。

もしかしたら違う環境だと何か変わるかも……と思い、改めてLinux環境でESP-IDF v5.4で確認したところ、今後はstuckするような動作となっています。 単にコンソール出力が無いだけ? QEMUのモニターからinfo registersするとpcが変化していません。

何が悪いかさっぱりです。 実機のフラッシュ(arduino-esp32由来)をコピーしてQEMUに渡しても動かない!

QEMUは完全に実機とはいえず、おそらく何らかのサポートしていない機能や命令を使おうとしている……?30 でも目的は「QEMUでArduinoフレームワークのアプリケーションを動かす」ではありません。 CIでQEMUを使うようなテストを想定しているなら厄介ですが、別にユニットテストで十分です。

この辺は見なかったことにして「QEMUでESP-IDFのアプリケーションを動かしながら、セキュリティ機能を試す」ことにしました。

今回はここまで

このあとは、実機でArduinoのアプリケーションを、セキュリティ機能を有効にしたりしなかったりして動かす予定です。 でも、記事が長くなりfootnotesの数も多すぎるので今回はここまで……いえ 🧍今回ここまで🧍 にします。

(追記) 続編、ESP32-C3のセキュリティ機能を試す(Arduino)もできました。

Footnotes

  1. eFuse IC(電子ヒューズ)とはなんですか? | 東芝デバイス&ストレージ株式会社 | 日本

  2. 厳密には「SPI Bootモードであれば、2ndステージブートローダを起動する」。

  3. ArduinoやESP-IDFを使っていれば、あまり意識しなくてもよしなにしてくれます。

  4. ESP32-H2ではECDSAによる署名も可能らしい。

  5. フラッシュの書き換えにより特定レジスタにコードを読み込ませられることと、Electromagnetic fault injectionを利用しプログラムカウンタを制御してセキュアブートを回避した事例: Jeroen Delvaux, et al. “Breaking Espressif’s ESP32 V3: Program Counter Control with Computed Values using Fault Injection” 18th USENIX WOOT Conference on Offensive Technologies (WOOT 24), 2024.

  6. ESP32-H2ではXTS-AES-256(鍵長512bit)も使えるらしい。

  7. 安全な鍵生成のためには十分なエントロピーが必要になる。

  8. 開発モードに限れば平文のイメージを書き込むオプションもあるようです。

  9. とはいえ、どちらの表現でも伝わることは伝わるでしょう。逆カプ、解釈違いほど問題にはならないと思われる。

  10. じゃあ「鍵長128bitの鍵を2つ(合計鍵長256bit)のXTS-AES-128」と言ったほうが良い?でもたとえばcryptographyライブラリでは鍵を1つだけを指定するので……。参考1: cryptographyのドキュメント、参考2: esptool内でXTS-AESを使っている箇所

  11. 1024bit (128 Bytes) 単位。1つのTweak内に8つの暗号化ブロックが生まれることになる。

  12. 学生時代を思い出して食欲不振、睡眠不足、動悸に眩暈に神経衰弱の症状が出そうなので調整の詳細は割愛します。GF(仮)。

  13. Karim M. Abdellatif, et al., “Unlimited Results: Breaking Firmware Encryption of ESP32-V3”, Cryptology ePrint Archive, 2023.

  14. Espressif ESP32: Breaking HW AES with Electromagnetic Analysis (raelize.com)

  15. Why doesn’t NVS Encryption use the standard Flash Encryption mechanism? - ESP32 Forum を見ましたがいまいち分からず……。

  16. 実装はmbedTLSを使っている模様。著名なライブラリなのでキッチリしていそうですが、サイドチャネル攻撃への耐性があるかどうかまでは追いきれなかった。

  17. https://github.com/espressif/esp-idf/blob/c5865270b50529cd32353f588d8a917d89f3dba4/components/nvs_sec_provider/nvs_sec_provider.c#L113

  18. Security Features Enablement Workflows#Enable NVS Encryption Based on HMAC - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation

  19. QEMU環境だとフラッシュサイズは2MB/4MB/8MB/16MBのいずれかにする必要があります。

  20. ESP32時代は0x1000だった模様。

  21. ESP32時代はフラッシュ暗号化を有効にするeFuseはFLASH_CRYPT_CNTだったりしますが、ESP32-C3ではSPI_BOOT_CRYPT_CNTになっているなど違いがあるので注意。

  22. 「起動後に」なので出荷前に動作確認などで一度は起動され、セキュリティ関連のeFuseが書かれている前提です。もし起動せず出荷すると、一部のセキュリティ機能が有効でない状態となり、ダウンロードモードに入られる可能性はあるかも知れません。

  23. “app is configured for RELEASE but efuses are set for DEVELOPMENT”などと言われてしまった場合はeFuseに書き込めておらず、esp_get_flash_encryption_mode()でDEVELOPMENTモードと判定されてしまっている。(参考: 当該メッセージのソースコード

  24. すべてのeFuseがそうか?というと自信はなく、おそらくget_security_infoでアクセスするeFuseくらいは読めそうですが……。

  25. 実機でも、esptool.pyに頼らず外付けフラッシュに直接アクセスすることで見える(見えてしまう)可能性はあります。

  26. 署名検証に失敗したら鍵を失効させるアプローチもあるようなので、それが有効だと正常動作に戻せなかったかも知れません。

  27. 暗号化されたNVS領域を読み取って復号、データを変更し再度暗号化、そして書き込み、という手順もありますが……実機のread_flashが使えずフラッシュの読み取りが難しい前提でも実用できるかは分かりません。

  28. 特に製品製造用としてテンプレートを使ってデータを作るユーティリティもある模様: Manufacturing Utility - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation

  29. 試していないので詳細は分かりませんが、一応ハードウェア支援もあるようなので……: Digital Signature (DS) - ESP32-C3 - — ESP-IDF Programming Guide v5.4 documentation

  30. この辺とか……ESP32C3 fails to boot with otadata partition and no factory app. (QEMU-160) · Issue #83 · espressif/qemu