├── .github └── workflows │ └── go.yml ├── .mailmap ├── LICENSE ├── README.md ├── clients ├── Android │ ├── README.md │ └── ultrablue │ │ ├── .gitignore │ │ ├── .idea │ │ ├── .gitignore │ │ ├── codeStyles │ │ │ ├── Project.xml │ │ │ └── codeStyleConfig.xml │ │ ├── compiler.xml │ │ ├── gradle.xml │ │ ├── inspectionProfiles │ │ │ └── Project_Default.xml │ │ ├── misc.xml │ │ └── vcs.xml │ │ ├── app │ │ ├── .gitignore │ │ ├── build.gradle │ │ ├── proguard-rules.pro │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── java │ │ │ └── fr │ │ │ │ └── gouv │ │ │ │ └── ssi │ │ │ │ └── ultrablue │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── database │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── Device.kt │ │ │ │ ├── DeviceDao.kt │ │ │ │ ├── DeviceRepository.kt │ │ │ │ └── DeviceViewModel.kt │ │ │ │ ├── fragments │ │ │ │ ├── DeviceFragment.kt │ │ │ │ ├── DeviceListFragment.kt │ │ │ │ └── ProtocolFragment.kt │ │ │ │ └── model │ │ │ │ ├── Addr.kt │ │ │ │ ├── DeviceAdapter.kt │ │ │ │ ├── Logger.kt │ │ │ │ └── UltrablueProtocol.kt │ │ │ └── res │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable │ │ │ ├── ic_baseline_add_24.xml │ │ │ ├── ic_baseline_delete_24.xml │ │ │ ├── ic_baseline_delete_24_selected.xml │ │ │ ├── ic_baseline_edit_24.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_round_play_arrow_24.xml │ │ │ └── ic_round_play_arrow_24_selected.xml │ │ │ ├── layout │ │ │ ├── activity_main.xml │ │ │ ├── device_card_view.xml │ │ │ ├── fragment_device.xml │ │ │ ├── fragment_device_list.xml │ │ │ └── fragment_protocol.xml │ │ │ ├── menu │ │ │ └── action_bar.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.webp │ │ │ └── ic_launcher_round.webp │ │ │ ├── navigation │ │ │ └── nav_graph.xml │ │ │ ├── values-night │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── build.gradle │ │ ├── gradle.properties │ │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ │ ├── gradlew │ │ ├── gradlew.bat │ │ └── settings.gradle ├── README.md ├── go-mobile │ ├── .gitignore │ ├── README.md │ ├── clean.sh │ ├── client.go │ ├── create_archive.sh │ ├── go.mod │ └── go.sum └── ios │ └── README.md ├── doc ├── presentations │ └── ultrablue-buckwell-kerneis-bouchinet-osfc-2022.pdf └── protocol │ ├── characteristics_protocol.svg │ └── characteristics_protocol.txt └── server ├── .gitignore ├── README.md ├── characteristic.go ├── characteristic_test.go ├── dracut ├── 90crypttab.conf └── 90ultrablue │ └── module-setup.sh ├── go.mod ├── go.sum ├── main.go ├── protocol.go ├── protocol_test.go ├── session.go ├── state.go ├── state_test.go ├── testbed ├── Makefile ├── README.md ├── mkosi.build ├── mkosi.default.d │ ├── 10-ultrablue.conf │ ├── arch │ │ └── 10-mkosi.arch │ ├── debian │ │ └── 10-mkosi.debian │ ├── fedora │ │ └── 10-mkosi.fedora │ ├── opensuse │ │ └── 10-mkosi.opensuse │ └── ubuntu │ │ └── 10-mkosi.ubuntu ├── mkosi.passphrase ├── mkosi.postinst └── ressources │ └── crypttab ├── tpm2.go ├── unit └── ultrablue-server.service ├── utils.go └── uuids.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "dev" ] 9 | pull_request: 10 | branches: [ "dev" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Build 25 | run: cd server && go build -v ./... 26 | 27 | - name: Test 28 | run: cd server && go test -v ./... 29 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Loic Buckwell 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ultrablue, a remote attestation server for your phone 2 | 3 | Ultrablue (User-friendly Lightweight TPM Remote Attestation over Bluetooth) is 4 | a solution to allow individual users to perform boot state attestation with 5 | their phone. 6 | It consists in a server, running on a computer, acting as the attester, and a 7 | graphical client application, running on a trusted phone, acting as the 8 | verifier. 9 | 10 | A typical use-case is to verify the integrity of your bootchain before 11 | unlocking your computer, to prevent offline attacks on an unattended laptop. It 12 | can also serve as a debugging tool for secure boot issues after firmware 13 | upgrades or a second factor for disk encryption. 14 | 15 | Once installed, the classical Ultrablue control flow consists of several steps: 16 | 17 | 1. Enrollment 18 | 2. Boot-time integration in the initramfs (optional) 19 | 3. Attestation 20 | 21 | ## 0. Installation 22 | 23 | Using Ultrablue requires a Linux computer that you want to attest the boot state of, 24 | featuring a TPM and a Bluetooth interface (typically a laptop), as well as mobile phone. 25 | 26 | * On the computer, you need to build and install the [Linux server](server). 27 | * On the mobile phone, you need to install either the [IOS client](clients/ios) 28 | or the [Android client](clients/Android). 29 | 30 | 31 | ## 1. Enrollment 32 | 33 | To enroll a phone as a verifier, start the server in enroll mode: 34 | 35 | ``` 36 | sudo ultrablue-server -enroll -pcr-extend 37 | ``` 38 | 39 | This will display a QR code on the terminal. From the phone, run the client 40 | app, and tap the **+** icon on the top right corner to show a QR code scanner. 41 | On scanning, a Bluetooth Low Energy channel will be established, and the 42 | enrollment will run automatically. Upon success, a device card will appear on 43 | the home page of the client application. 44 | 45 | **Ultrablue** can extend your **TPM2 PCR9** using a randomly generated value at 46 | enroll time. This is usefull if you want to, eg., bind your disk encryption to 47 | **TPM2 sealing**. In that case, **ultrablue** will extend back the **PCR9** 48 | register during boot-time if the attestation is successfull and trusted. 49 | PCR-extension is configured at enroll time (the flag has no effect in 50 | attestation mode): 51 | 52 | ``` 53 | sudo ultrablue-server -enroll -pcr-extend 54 | ``` 55 | 56 | ### 2. Boot-time integration using Dracut (optional) 57 | 58 | If you want ultrablue to execute as part of your boot flow, you have to 59 | re-generate your initramfs to bundle it in. We make this easier by providing 60 | Dracut and systemd integration. See also [the provided VM 61 | scripts](server/testbed) which provide an even easier way to test this. 62 | 63 | First, install `server/dracut/90ultrablue` in the `/usr/lib/dracut/modules.d/` module directory, 64 | 65 | You can then run the following dracut command: 66 | 67 | ```bash 68 | dracut --add ultrablue /path/to/initrd --force 69 | ``` 70 | 71 | If you used the `--pcr-extend` option during the enrollment phase, you'll need 72 | to add the **crypt** dracut module, and to copy`server/dracut/90crypttab.conf` in `/etc/dracut.conf.d` (to work around [dracut bug #751640](https://bugzilla.redhat.com/show_bug.cgi?id=751640#c18)): 73 | 74 | ```bash 75 | cp server/dracut/90crypttab.conf /etc/dracut.conf.d 76 | dracut --add "crypt ultrablue" /path/to/initrd --force 77 | ``` 78 | 79 | Note that those options are not persistent and **ultrablue** will be removed 80 | from your initramfs on its next generation. See the dracut.conf(5) man page for 81 | persistent configuration. 82 | 83 | ## 3. Attestation 84 | 85 | If you did the initramfs configuration step, Ultrablue server will run 86 | automatically during the boot. Otherwise, manually start the server in 87 | attestation mode: 88 | ```bash 89 | sudo ultrablue-server 90 | ``` 91 | 92 | Once started, the server will wait for a verifier (phone) to connect. From the 93 | phone, click on the **▶️** icon of the device card. This will run the 94 | attestation. When finished, the client application will display the attestation 95 | result. 96 | 97 | ## 4. Disk decryption based on remote attestation 98 | 99 | The main goal of running ultrablue at boot time is to use it for disk decryption. 100 | An example of how to do this is provided and documented in the [server 101 | testbed](server/testbed). 102 | 103 | ## Contact 104 | 105 | The Ultrablue project has been developped at ANSSI 106 | ([ssi.gouv.fr](http://ssi.gouv.fr)) by Loïc Buckwell, under the supervision of 107 | Nicolas Bouchinet and Gabriel Kerneis. 108 | 109 | If you have any question, reach out via a Github issue, or directly to Gabriel 110 | Kerneis . 111 | -------------------------------------------------------------------------------- /clients/Android/README.md: -------------------------------------------------------------------------------- 1 | # Ultrablue Android client 2 | 3 | This directory contains an implementation of the Ultrablue client for Android. 4 | 5 | ## Getting started 6 | 7 | 1. Download Android Studio for your platform: 8 | https://developer.android.com/studio Ultrablue is developed using Linux, 9 | other platforms have not been tested. 10 | 2. Install Android Studio for your platform: 11 | https://developer.android.com/studio/install 12 | 3. Install the Android NDK for your platform. There are several ways to do this, but the easiest is probably to get it from Android Studio (`Tools > SDK Manager > SDK Tools > NDK`). 13 | Alternatively, you can download the latest version and unpack it in `~/Android/Sdk/ndk-bundle`: https://developer.android.com/ndk/downloads 14 | 4. Launch Android Studio and import the `ultrablue` project. 15 | 5. Configure a device to run the app: 16 | https://developer.android.com/studio/run/device 17 | Under Linux, pay special attention to the part about udev permissions. 18 | 6. **The Android client depends on the go-mobile library.** 19 | You need to build it first: see instructions in [clients/go-mobile](../go-mobile/README.md). 20 | 7. You are now ready to build and run the app: 21 | https://developer.android.com/studio/run 22 | 23 | ## How to test the app 24 | 25 | Ultrablue is tested manually using a Pixel 4a. 26 | There is no way to fully test the client in the Android emulator because the 27 | emulator does not support bluetooth. 28 | 29 | Start the server in enroll mode: 30 | 31 | ``` 32 | $ sudo server/ultrablue_server --enroll 33 | ``` 34 | 35 | This should display a QR Code. 36 | See [server/README.md](../../server/) for more instructions about building and 37 | running the server. 38 | 39 | 40 | Start the client app on your phone (from Android Studio), and follow the 41 | instructions displayed by the server to scan the QR Code. 42 | 43 | ## Troubleshooting 44 | 45 | * If your build fails with an error message about gomobile, make sure you have built [the go-mobile library](../go-mobile/README.md). 46 | * Under Linux, if your build fails, make sure [the tmp directory has exec 47 | permissions](https://github.com/xerial/sqlite-jdbc/issues/97#issuecomment-220855060). 48 | * If your emulator fails to start under Wayland, check that you have `xcb` 49 | listed as a fallback in `$QT_QPA_PLATFORM`. Start the emulator manually in a 50 | terminal to get useful error messages: `~/Android/Sdk/emulator/emulator @Pixel_4a_API_30`. 51 | 52 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | gomobile.aar 17 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 49 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'kotlin-kapt' 5 | id 'kotlinx-serialization' 6 | } 7 | 8 | android { 9 | compileSdk 32 10 | 11 | defaultConfig { 12 | applicationId "fr.gouv.ssi.ultrablue" 13 | namespace "fr.gouv.ssi.ultrablue" 14 | minSdk 30 15 | targetSdk 32 16 | versionCode 1 17 | versionName "1.0" 18 | 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | kotlinOptions { 33 | jvmTarget = '1.8' 34 | } 35 | } 36 | 37 | dependencies { 38 | 39 | implementation 'androidx.core:core-ktx:1.8.0' 40 | implementation 'androidx.appcompat:appcompat:1.4.2' 41 | implementation 'com.google.android.material:material:1.6.1' 42 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 43 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 44 | implementation("io.github.g00fy2.quickie:quickie-bundled:1.4.0") 45 | implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0' 46 | implementation 'androidx.navigation:navigation-ui-ktx:2.5.0' 47 | testImplementation 'junit:junit:4.13.2' 48 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 49 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 50 | 51 | def roomVersion = "2.4.3" 52 | implementation("androidx.room:room-runtime:$roomVersion") 53 | kapt("androidx.room:room-compiler:$roomVersion") 54 | implementation "androidx.room:room-ktx:$roomVersion" 55 | 56 | implementation "org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.4.0-RC" 57 | 58 | implementation files("libs/gomobile.aar") 59 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.navigation.findNavController 7 | import fr.gouv.ssi.ultrablue.database.DeviceViewModel 8 | 9 | class MainActivity : AppCompatActivity() { 10 | lateinit var viewModel: DeviceViewModel 11 | 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | viewModel = ViewModelProvider(this)[DeviceViewModel::class.java] 15 | setContentView(R.layout.activity_main) 16 | setSupportActionBar(findViewById(R.id.toolbar)) 17 | } 18 | 19 | override fun onSupportNavigateUp(): Boolean { 20 | val navController = this.findNavController(R.id.fragmentContainerView) 21 | return navController.navigateUp() 22 | } 23 | 24 | fun showUpButton() { 25 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 26 | } 27 | 28 | fun hideUpButton() { 29 | supportActionBar?.setDisplayHomeAsUpEnabled(false) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.database 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | 8 | /* 9 | Provides a singleton to access the app_database. 10 | */ 11 | @Database(entities = [Device::class], version = 4, exportSchema = false) 12 | abstract class AppDatabase : RoomDatabase() { 13 | 14 | abstract fun deviceDao(): DeviceDao 15 | 16 | companion object { 17 | @Volatile 18 | private var INSTANCE: AppDatabase? = null 19 | fun getDatabase(context: Context): AppDatabase { 20 | return INSTANCE ?: synchronized(this) { 21 | val instance = Room.databaseBuilder( 22 | context.applicationContext, 23 | AppDatabase::class.java, 24 | "app_database" 25 | ) 26 | .fallbackToDestructiveMigration() 27 | .allowMainThreadQueries() 28 | .build() 29 | INSTANCE = instance 30 | return instance 31 | } 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/database/Device.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.database 2 | 3 | import androidx.room.* 4 | import java.io.Serializable 5 | 6 | /* 7 | This table stores the registered devices. 8 | */ 9 | @Entity(tableName = "device_table") 10 | data class Device ( 11 | @PrimaryKey(autoGenerate = true) 12 | val uid: Int, 13 | var name: String, // user-defined device name 14 | var addr: String, // MAC address 15 | var ekn: ByteArray, // Public part of the Endorsement Key 16 | var eke: Int, // Exponent of the Endorsement Key 17 | var ekcert: ByteArray, // Raw certificate for the Endorsement Key 18 | var encodedPCRs: ByteArray, // PCRs we got at enrollment 19 | var secret: ByteArray // Secret to send to the attester on attestation success, in order to extend a PCR 20 | ) : Serializable { 21 | override fun equals(other: Any?): Boolean { 22 | if (this === other) return true 23 | if (javaClass != other?.javaClass) return false 24 | 25 | other as Device 26 | 27 | if (uid != other.uid) return false 28 | 29 | return true 30 | } 31 | 32 | override fun hashCode(): Int { 33 | return uid 34 | } 35 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/database/DeviceDao.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.database 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.* 5 | 6 | /* 7 | Queries to manage device_table 8 | */ 9 | @Dao 10 | interface DeviceDao { 11 | 12 | @Insert(onConflict = OnConflictStrategy.IGNORE) 13 | suspend fun addDevice(device: Device) 14 | 15 | @Query("SELECT * FROM device_table") 16 | fun getAll(): LiveData> 17 | 18 | @Query("SELECT * FROM device_table WHERE uid=:uid") 19 | fun get(uid: Int): Device 20 | 21 | @Query("SELECT * FROM device_table WHERE addr=:addr") 22 | fun get(addr: String): Device 23 | 24 | @Query("UPDATE device_table SET name=:newName WHERE uid=:uid") 25 | fun setName(uid: Int, newName: String) 26 | 27 | @Query("UPDATE device_table SET encodedPCRs=:newPCRs WHERE uid=:uid") 28 | fun setPCRs(uid: Int, newPCRs: ByteArray) 29 | 30 | @Delete 31 | suspend fun removeDevice(device: Device) 32 | } 33 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/database/DeviceRepository.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.database 2 | 3 | import androidx.lifecycle.LiveData 4 | 5 | /* 6 | This class provides an abstraction layer to manage the device_table. 7 | */ 8 | class DeviceRepository(private val deviceDao: DeviceDao) { 9 | val allDevices: LiveData> = deviceDao.getAll() 10 | 11 | suspend fun insert(device: Device) { 12 | deviceDao.addDevice(device) 13 | } 14 | 15 | fun get(id: Int) : Device { 16 | return deviceDao.get(id) 17 | } 18 | 19 | fun get(addr: String) : Device { 20 | return deviceDao.get(addr) 21 | } 22 | 23 | suspend fun delete(device: Device) { 24 | deviceDao.removeDevice(device) 25 | } 26 | 27 | fun setName(device: Device, newName: String) { 28 | deviceDao.setName(device.uid, newName) 29 | } 30 | 31 | // TODO: This function is meant to be used when values of some PCRs changed 32 | // but we now it is the result of a trusted action. We want to allow the user 33 | // to take the new PCR values as reference values. 34 | fun setPCRs(device: Device, newPCRs: ByteArray) { 35 | deviceDao.setPCRs(device.uid, newPCRs) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/database/DeviceViewModel.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.database 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.AndroidViewModel 5 | import androidx.lifecycle.LiveData 6 | import androidx.lifecycle.viewModelScope 7 | import kotlinx.coroutines.launch 8 | 9 | /* 10 | Implements a View Model to access and update the database 11 | */ 12 | class DeviceViewModel(application: Application): AndroidViewModel(application) { 13 | 14 | val repo: DeviceRepository 15 | val allDevices: LiveData> 16 | 17 | init { 18 | val deviceDao = AppDatabase.getDatabase(application).deviceDao() 19 | repo = DeviceRepository(deviceDao) 20 | allDevices = repo.allDevices 21 | } 22 | 23 | fun insert(device: Device) = viewModelScope.launch { 24 | repo.insert(device) 25 | } 26 | 27 | fun delete(device: Device) = viewModelScope.launch { 28 | repo.delete(device) 29 | } 30 | 31 | fun rename(device: Device, name: String) { 32 | repo.setName(device, name) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/fragments/DeviceFragment.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.fragments 2 | 3 | import android.app.AlertDialog 4 | import android.os.Bundle 5 | import android.view.* 6 | import android.widget.EditText 7 | import android.widget.TextView 8 | import androidx.core.view.MenuProvider 9 | import androidx.fragment.app.Fragment 10 | import fr.gouv.ssi.ultrablue.database.DeviceViewModel 11 | import fr.gouv.ssi.ultrablue.MainActivity 12 | import fr.gouv.ssi.ultrablue.R 13 | import fr.gouv.ssi.ultrablue.database.Device 14 | 15 | /* 16 | This fragment displays the details about a specific Device. 17 | */ 18 | class DeviceFragment : Fragment() { 19 | private var viewModel: DeviceViewModel? = null 20 | private var device: Device? = null 21 | 22 | /* 23 | Fragment lifecycle methods: 24 | */ 25 | 26 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 27 | val menuHost = requireActivity() 28 | menuHost.addMenuProvider(object: MenuProvider { 29 | override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { 30 | menuInflater.inflate(R.menu.action_bar, menu) 31 | } 32 | override fun onMenuItemSelected(item: MenuItem): Boolean { 33 | return when (item.itemId) { 34 | // The + button has been clicked 35 | R.id.action_edit -> { 36 | showDeviceRenamingDialog() 37 | true 38 | } 39 | else -> false 40 | } 41 | } 42 | override fun onPrepareMenu(menu: Menu) { 43 | super.onPrepareMenu(menu) 44 | activity?.title = "${device?.name}" 45 | menu.findItem(R.id.action_edit).isVisible = true 46 | menu.findItem(R.id.action_add).isVisible = false 47 | } 48 | }) 49 | (activity as MainActivity).showUpButton() 50 | device = requireArguments().getSerializable("device") as Device 51 | return inflater.inflate(R.layout.fragment_device, container, false) 52 | } 53 | 54 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 55 | super.onViewCreated(view, savedInstanceState) 56 | viewModel = (activity as MainActivity).viewModel 57 | displayDeviceInformation(view) 58 | } 59 | 60 | override fun onDestroyView() { 61 | super.onDestroyView() 62 | (activity as MainActivity).hideUpButton() 63 | } 64 | 65 | // Sets the view components content with the device information. 66 | private fun displayDeviceInformation(view: View) { 67 | val tv: TextView = view.findViewById(R.id.addr_value) 68 | tv.text = "${device?.addr}" 69 | } 70 | 71 | // Present a popup allowing the user to rename the device. 72 | private fun showDeviceRenamingDialog() { 73 | val nameField = EditText(requireContext()) 74 | nameField.hint = "name" 75 | nameField.width = 150 76 | nameField.setPadding(30, 30, 30, 30) 77 | val alertDialogBuilder = AlertDialog.Builder(activity) 78 | alertDialogBuilder 79 | .setTitle(R.string.rename_device_dialog_title) 80 | .setView(nameField) 81 | .setPositiveButton("Ok") { _, _ -> 82 | device?.let { 83 | if (isNameValid(nameField.text.toString())) { 84 | renameDevice(it, nameField.text.toString()) 85 | activity?.title = it.name 86 | } 87 | } 88 | } 89 | .setNegativeButton("Cancel", null) 90 | .show() 91 | } 92 | 93 | /* 94 | Fragment methods 95 | */ 96 | 97 | // Checking the validity of a device name. 98 | // Currently length based, but could be improved, e.g. by only accepting alphanumeric characters 99 | private fun isNameValid(name: String) : Boolean { 100 | return name.length in 4..12 101 | } 102 | 103 | // Change the device name in the database. 104 | private fun renameDevice(dev: Device, name: String) { 105 | dev.name = name 106 | viewModel?.rename(dev, name) 107 | } 108 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/fragments/DeviceListFragment.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.fragments 2 | 3 | import android.app.AlertDialog 4 | import android.os.Bundle 5 | import android.view.* 6 | import androidx.core.os.bundleOf 7 | import androidx.core.view.MenuProvider 8 | import androidx.fragment.app.Fragment 9 | import androidx.navigation.NavHostController 10 | import androidx.navigation.findNavController 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import androidx.recyclerview.widget.RecyclerView 13 | import fr.gouv.ssi.ultrablue.* 14 | import fr.gouv.ssi.ultrablue.database.Device 15 | import fr.gouv.ssi.ultrablue.database.DeviceViewModel 16 | import io.github.g00fy2.quickie.QRResult 17 | import io.github.g00fy2.quickie.ScanCustomCode 18 | import io.github.g00fy2.quickie.config.BarcodeFormat 19 | import io.github.g00fy2.quickie.config.ScannerConfig 20 | 21 | /* 22 | * This fragment displays a list of registered devices. 23 | * */ 24 | class DeviceListFragment : Fragment(R.layout.fragment_device_list), ItemClickListener { 25 | private val scanner = registerForActivityResult(ScanCustomCode(), ::onQRCodeScannerResult) 26 | private var viewModel: DeviceViewModel? = null 27 | 28 | /* 29 | Fragment lifecycle methods: 30 | */ 31 | 32 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 33 | val menuHost = requireActivity() 34 | menuHost.addMenuProvider(object: MenuProvider { 35 | override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { 36 | activity?.title = "Your devices" 37 | menuInflater.inflate(R.menu.action_bar, menu) 38 | } 39 | override fun onMenuItemSelected(item: MenuItem): Boolean { 40 | return when (item.itemId) { 41 | // The + button has been clicked 42 | R.id.action_add -> { 43 | showQRCodeScanner() 44 | true 45 | } 46 | else -> false 47 | } 48 | } 49 | override fun onPrepareMenu(menu: Menu) { 50 | super.onPrepareMenu(menu) 51 | menu.findItem(R.id.action_add).isVisible = true 52 | menu.findItem(R.id.action_edit).isVisible = false 53 | } 54 | }) 55 | return inflater.inflate(R.layout.fragment_device_list, container, false) 56 | } 57 | 58 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 59 | super.onViewCreated(view, savedInstanceState) 60 | viewModel = (activity as MainActivity).viewModel 61 | setUpDeviceRecyclerView(view) 62 | } 63 | 64 | // Handle clicks on a specific device card. 65 | override fun onClick(id: ItemClickListener.Target, device: Device) { 66 | when (id) { 67 | ItemClickListener.Target.CARD_VIEW -> { 68 | val nc = activity?.findNavController(R.id.fragmentContainerView) as NavHostController 69 | val bundle = bundleOf("device" to device) 70 | nc.navigate(R.id.action_deviceListFragment_to_deviceFragment, bundle) 71 | } 72 | ItemClickListener.Target.ATTESTATION_BUTTON -> { 73 | val nc = activity?.findNavController(R.id.fragmentContainerView) as NavHostController 74 | val bundle = bundleOf("device" to device) 75 | nc.navigate(R.id.action_deviceListFragment_to_protocolFragment, bundle) 76 | } 77 | ItemClickListener.Target.TRASH_BUTTON -> { 78 | val alertDialogBuilder = AlertDialog.Builder(activity) 79 | alertDialogBuilder 80 | .setTitle(R.string.delete_device_dialog_title) 81 | .setMessage(getString(R.string.delete_device_dialog_body, device.name)) 82 | .setPositiveButton(R.string.delete_label) { _, _ -> 83 | viewModel?.delete(device) 84 | } 85 | .setNegativeButton(R.string.cancel_label, null) 86 | .show() 87 | } 88 | } 89 | } 90 | 91 | /* 92 | Fragment methods 93 | */ 94 | 95 | private fun setUpDeviceRecyclerView(view: View) { 96 | val recyclerview = view.findViewById(R.id.recyclerview) 97 | val adapter = DeviceAdapter(this) 98 | recyclerview.layoutManager = LinearLayoutManager(requireContext()) 99 | viewModel?.allDevices?.observe(viewLifecycleOwner) { items -> 100 | adapter.setRegisteredDevices(items) 101 | } 102 | recyclerview.adapter = adapter 103 | } 104 | 105 | private fun showQRCodeScanner() { 106 | scanner.launch( 107 | ScannerConfig.build { 108 | setBarcodeFormats(listOf(BarcodeFormat.FORMAT_QR_CODE)) 109 | setOverlayStringRes(R.string.qrcode_scanner_subtitle) 110 | } 111 | ) 112 | } 113 | 114 | /* 115 | When receiving QR code data, this function checks for potential 116 | errors, which can be: 117 | - Scanning error 118 | - Invalid received data 119 | If an error occurred, an alert is displayed. 120 | Otherwise, we navigate to the protocol fragment. 121 | */ 122 | private fun onQRCodeScannerResult(result: QRResult) { 123 | when(result) { 124 | is QRResult.QRSuccess -> { 125 | if (isMACAddressValid(result.content.rawValue.trim())) { 126 | val device = Device(0, "", result.content.rawValue.trim(), byteArrayOf(), 0, byteArrayOf(), byteArrayOf(), byteArrayOf()) 127 | val nc = activity?.findNavController(R.id.fragmentContainerView) as NavHostController 128 | val bundle = bundleOf("device" to device) 129 | nc.navigate(R.id.action_deviceListFragment_to_protocolFragment, bundle) 130 | } else { 131 | showErrorPopup(getString(R.string.qrcode_error_invalid_title), getString(R.string.qrcode_error_invalid_message)) 132 | } 133 | } 134 | is QRResult.QRError -> 135 | showErrorPopup(getString(R.string.qrcode_error_failure_title), getString(R.string.qrcode_error_failure_message)) 136 | is QRResult.QRMissingPermission -> 137 | showErrorPopup(getString(R.string.qrcode_error_camera_permission_title), getString(R.string.qrcode_error_camera_permission_message)) 138 | is QRResult.QRUserCanceled -> { } 139 | } 140 | } 141 | 142 | private fun showErrorPopup(title: String, message: String) { 143 | val alertDialogBuilder = AlertDialog.Builder(activity) 144 | alertDialogBuilder 145 | .setTitle(title) 146 | .setMessage(message) 147 | .setPositiveButton("Ok", null) 148 | .show() 149 | } 150 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/fragments/ProtocolFragment.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.fragments 2 | 3 | import android.Manifest 4 | import android.annotation.SuppressLint 5 | import android.app.Activity 6 | import android.bluetooth.* 7 | import android.bluetooth.le.ScanCallback 8 | import android.bluetooth.le.ScanResult 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.net.MacAddress 12 | import android.os.Build 13 | import android.os.Bundle 14 | import android.os.Handler 15 | import android.os.Looper 16 | import android.view.* 17 | import androidx.activity.result.contract.ActivityResultContracts 18 | import androidx.core.view.MenuProvider 19 | import androidx.fragment.app.Fragment 20 | import fr.gouv.ssi.ultrablue.MainActivity 21 | import fr.gouv.ssi.ultrablue.R 22 | import fr.gouv.ssi.ultrablue.database.Device 23 | import fr.gouv.ssi.ultrablue.model.* 24 | import java.util.* 25 | 26 | const val MTU = 512 27 | 28 | val ultrablueSvcUUID: UUID = UUID.fromString("ebee1789-50b3-4943-8396-16c0b7231cad") 29 | val ultrablueChrUUID: UUID = UUID.fromString("ebee1790-50b3-4943-8396-16c0b7231cad") 30 | 31 | /* 32 | This is the fragment where the attestation actually happens. 33 | If the passed device has a name, we start a classic attestation for it. 34 | Else, it means it just has been created by the calling fragment, and thus 35 | we start the attestation in enroll mode. 36 | 37 | This file contains the Bluetooth handling code, including the scanning/connecting/services and 38 | characteristics discovering, and also the functions to read/write on the characteristics. 39 | The protocol itself is defined in the UltrablueProtocol.kt file. 40 | */ 41 | class ProtocolFragment : Fragment() { 42 | private var enroll = false 43 | private var logger: Logger? = null 44 | 45 | private var protocol: UltrablueProtocol? = null 46 | 47 | // Timer related variables 48 | // Limit some tasks duration, to avoid waiting forever 49 | private var timer = Handler(Looper.getMainLooper()) 50 | private val timeoutPeriod: Long = 3000 // 3 seconds 51 | 52 | // Data structure for packet reconstruction. Used in the onCharacteristicRead callback. 53 | private var data = ByteArray(0) 54 | private var dataLen: Int = 0 55 | 56 | // To enhance readability of this callback heavy code, it helps to name them, and assign them 57 | // later, at their logical point in the code (even if we could have implemented them as methods 58 | // instead). 59 | private var onBluetoothActivationCallback: (() -> Unit)? = null 60 | private var onLocationPermissionGrantedCallback: (() -> Unit)? = null 61 | private var onDeviceConnectionCallback: ((BluetoothGatt) -> Unit)? = null 62 | private var onMTUChangedCallback: (() -> Unit)? = null 63 | private var onServicesFoundCallback: (() -> Unit)? = null 64 | private var gatt: BluetoothGatt? = null 65 | 66 | private var onDeviceDisconnectionCallback: (() -> Unit) = { 67 | logger?.push(CLog("Disconnected from device", false)) 68 | } 69 | 70 | /* 71 | The following variables register activities that will be started later in the program. 72 | They must be declared during the fragment initialization, because of the Android API. 73 | */ 74 | 75 | // Asks the user to enable bluetooth if it is not already turned on. 76 | private val bluetoothActivationRequest = registerForActivityResult( 77 | ActivityResultContracts.StartActivityForResult() 78 | ) { result -> 79 | if (result.resultCode == Activity.RESULT_OK) { 80 | logger?.push(CLog("Bluetooth turned on", true)) 81 | onBluetoothActivationCallback?.invoke() 82 | } else { 83 | logger?.push(CLog("Failed to turn Bluetooth on", false)) 84 | } 85 | } 86 | 87 | // Asks the user to grant location permission if not already granted. 88 | private val permissionsRequest = registerForActivityResult( 89 | ActivityResultContracts.RequestMultiplePermissions() 90 | ) { perms -> 91 | var granted = true 92 | for (perm in perms) { 93 | if (perm.value) { 94 | logger?.push(CLog("${perm.key.split('.')[2]} permission granted", true)) 95 | } else { 96 | logger?.push(CLog("${perm.key.split('.')[2]} permission denied", false)) 97 | granted = false 98 | } 99 | } 100 | if (granted) { 101 | onLocationPermissionGrantedCallback?.invoke() 102 | } 103 | } 104 | 105 | /* 106 | Once we're connected to a device, this object's methods will be called back 107 | for every Bluetooth action, thus it implements all the logic we 108 | need such as read/write operations, connection updates, etc. 109 | */ 110 | private val gattHandler = object : BluetoothGattCallback() { 111 | @SuppressLint("MissingPermission") 112 | override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { 113 | super.onConnectionStateChange(gatt, status, newState) 114 | if (newState == BluetoothProfile.STATE_CONNECTED) { 115 | logger?.push(CLog("Connected to device", true)) 116 | onDeviceConnectionCallback?.invoke(gatt!!) 117 | } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { 118 | onDeviceDisconnectionCallback.invoke() 119 | } 120 | } 121 | 122 | override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { 123 | super.onMtuChanged(gatt, mtu, status) 124 | if (status == BluetoothGatt.GATT_SUCCESS) { 125 | logger?.push(CLog("MTU has been updated", true)) 126 | onMTUChangedCallback?.invoke() 127 | } 128 | } 129 | 130 | override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { 131 | super.onServicesDiscovered(gatt, status) 132 | if (status == BluetoothGatt.GATT_SUCCESS) { 133 | onServicesFoundCallback?.invoke() 134 | } 135 | } 136 | 137 | // As packets will arrive by chunks, we need to reconstruct them. 138 | // The size of the full message is encoded in little endian, and 139 | // is put at the start of the first packet, on 4 bytes. 140 | // When the full message is read, we call the protocol's onMessageRead 141 | // callback. 142 | @SuppressLint("MissingPermission") 143 | override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { 144 | super.onCharacteristicRead(gatt, characteristic, status) 145 | 146 | if (status == BluetoothGatt.GATT_SUCCESS) { 147 | protocol?.let { 148 | val bytes = characteristic.value 149 | if (dataLen == 0) { 150 | dataLen = byteArrayToInt(bytes.take(4)) 151 | data = bytes.drop(4).toByteArray() 152 | logger?.push(PLog(dataLen)) 153 | } else { 154 | data += bytes 155 | } 156 | logger?.update(data.size) 157 | 158 | if (data.size < dataLen) { 159 | gatt.readCharacteristic(characteristic) 160 | } else { 161 | val msg = data 162 | dataLen = 0 163 | data = byteArrayOf() 164 | it.onMessageRead(msg) 165 | } 166 | } 167 | } 168 | } 169 | 170 | /* 171 | As messages the client sends will never be bigger than the MTU, we don't need to care 172 | about chunking them. 173 | */ 174 | override fun onCharacteristicWrite(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { 175 | super.onCharacteristicWrite(gatt, characteristic, status) 176 | protocol?.let { proto -> 177 | characteristic?.let { 178 | proto.onMessageWrite() 179 | } 180 | } 181 | } 182 | } 183 | 184 | /* 185 | Fragment lifecycle methods: 186 | */ 187 | 188 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 189 | val menuHost = requireActivity() 190 | menuHost.addMenuProvider(object: MenuProvider { 191 | override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { 192 | menuInflater.inflate(R.menu.action_bar, menu) 193 | } 194 | override fun onPrepareMenu(menu: Menu) { 195 | super.onPrepareMenu(menu) 196 | val device = requireArguments().getSerializable("device") as Device 197 | activity?.title = if (device.name.isEmpty()) { 198 | "Enrollment in progress" 199 | } else { 200 | "Attestation in progress" 201 | } 202 | menu.findItem(R.id.action_edit).isVisible = false 203 | menu.findItem(R.id.action_add).isVisible = false 204 | } 205 | override fun onMenuItemSelected(menuItem: MenuItem): Boolean { 206 | return false 207 | } 208 | }) 209 | (activity as MainActivity).showUpButton() 210 | return inflater.inflate(R.layout.fragment_protocol, container, false) 211 | } 212 | 213 | @SuppressLint("MissingPermission") 214 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 215 | super.onViewCreated(view, savedInstanceState) 216 | val device = requireArguments().getSerializable("device") as Device 217 | if (device.name.isEmpty()) { 218 | enroll = true 219 | } 220 | logger = Logger( 221 | activity as MainActivity?, 222 | view.findViewById(R.id.logger_text_view), 223 | view.findViewById(R.id.logger_scroll_view), 224 | onError = { 225 | gatt?.disconnect() 226 | } 227 | ) 228 | 229 | /* 230 | As the following operations are not blocking, we let them run and give them 231 | a callback to call when completed. 232 | Briefly, those operations start Bluetooth, search for the attesting device, 233 | connect to it, and then start the Ultrablue Protocol. 234 | 235 | We deliberately break indentation to make this callback-based code read like 236 | direct style. 237 | */ 238 | askForBluetoothPermissions(onSuccess = { 239 | val btAdapter = getBluetoothAdapter() 240 | turnBluetoothOn(btAdapter, onSuccess = { 241 | scanForDevice(btAdapter, MacAddress.fromString(device.addr), onDeviceFound = { btDevice -> 242 | connectToDevice(btDevice, onSuccess = { gatt -> 243 | this.gatt = gatt 244 | requestMTU(gatt, MTU, onSuccess = { 245 | searchForUltrablueService(gatt, onServiceFound = { service -> 246 | val chr = service.getCharacteristic(ultrablueChrUUID) 247 | protocol = UltrablueProtocol( 248 | (activity as MainActivity), enroll, device, logger, 249 | readMsg = { tag -> 250 | logger?.push(Log("Getting $tag")) 251 | gatt.readCharacteristic(chr) 252 | }, 253 | writeMsg = { tag, msg -> 254 | val prepended = intToByteArray(msg.size) + msg 255 | if (prepended.size > MTU) { 256 | logger?.push( 257 | CLog("$tag doesn't fit in one packet: message size = ${prepended.size}", false) 258 | ) 259 | } else { 260 | logger?.push(Log("Sending $tag")) 261 | chr.value = prepended 262 | gatt.writeCharacteristic(chr) 263 | } 264 | }, 265 | onCompletion = { success -> 266 | if (success) { 267 | logger?.push(CLog("Attestation success", true)) 268 | } else { 269 | logger?.push(CLog("Attestation failure", false)) 270 | } 271 | logger?.push(Log("Closing connection")) 272 | // The protocol is over, thus it's no longer an error to be disconnected as we asked for it. 273 | onDeviceDisconnectionCallback = { 274 | logger?.push(CLog("Device disconnected", true)) 275 | } 276 | gatt.disconnect() 277 | if (success && (enroll || device.secret.isNotEmpty())) { 278 | (activity as MainActivity).onSupportNavigateUp() 279 | } 280 | } 281 | ) 282 | protocol?.start() 283 | }) }) }) }) }) }) 284 | } 285 | 286 | override fun onDestroyView() { 287 | super.onDestroyView() 288 | gatt?.disconnect() 289 | logger?.reset() 290 | (activity as MainActivity).hideUpButton() 291 | } 292 | 293 | /* 294 | The methods below implement each step of the initial setup. 295 | Most of them call asynchronous APIs and set up callbacks to plumb the results to. 296 | */ 297 | 298 | private fun askForBluetoothPermissions(onSuccess: () -> Unit) { 299 | onLocationPermissionGrantedCallback = onSuccess 300 | var perms = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) 301 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 302 | perms += Manifest.permission.BLUETOOTH_CONNECT 303 | perms += Manifest.permission.BLUETOOTH_SCAN 304 | } 305 | permissionsRequest.launch(perms) 306 | } 307 | 308 | private fun getBluetoothAdapter(): BluetoothAdapter { 309 | logger?.push(Log("Getting Bluetooth adapter")) 310 | val manager = requireContext().getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager 311 | val adapter = manager.adapter 312 | logger?.push(CLog("Got Bluetooth adapter", true)) 313 | return adapter 314 | } 315 | 316 | private fun turnBluetoothOn(adapter: BluetoothAdapter, onSuccess: () -> Unit) { 317 | logger?.push(Log("Checking for Bluetooth")) 318 | if (adapter.isEnabled) { 319 | logger?.push(CLog("Bluetooth is on", true)) 320 | onSuccess() 321 | } else { 322 | val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) 323 | logger?.push(Log("Bluetooth is off. Turning on")) 324 | onBluetoothActivationCallback = onSuccess 325 | bluetoothActivationRequest.launch(intent) 326 | } 327 | } 328 | 329 | @SuppressLint("MissingPermission") 330 | private fun scanForDevice(adapter: BluetoothAdapter, address: MacAddress, onDeviceFound: (device: BluetoothDevice) -> Unit) { 331 | var runnable: Runnable? = null 332 | 333 | val scanCallback = object: ScanCallback() { 334 | var deviceFound = false 335 | 336 | override fun onScanResult(callbackType: Int, result: ScanResult?) { 337 | super.onScanResult(callbackType, result) 338 | result?.let { scanResult -> 339 | if (MacAddress.fromString(scanResult.device.address) == address && !deviceFound) { 340 | deviceFound = true 341 | logger?.push(CLog("Found attesting device", true)) 342 | adapter.bluetoothLeScanner.stopScan(this) 343 | stopTimer(runnable!!) 344 | onDeviceFound(scanResult.device) 345 | } 346 | } 347 | } 348 | override fun onScanFailed(errorCode: Int) { 349 | super.onScanFailed(errorCode) 350 | logger?.push(CLog("Failed to scan", false)) 351 | } 352 | } 353 | 354 | logger?.push(Log("Scanning for attesting device")) 355 | runnable = startTimer(timeoutPeriod, onTimeout = { 356 | adapter.bluetoothLeScanner.stopScan(scanCallback) 357 | }) 358 | adapter.bluetoothLeScanner.startScan(scanCallback) 359 | } 360 | 361 | @SuppressLint("MissingPermission") // Permission has already been granted 362 | private fun connectToDevice(device: BluetoothDevice, onSuccess: (BluetoothGatt) -> Unit) { 363 | logger?.push(Log("Trying to connect")) 364 | val runnable = startTimer(timeoutPeriod, onTimeout = { /* let if fail, nothing more to do */}) 365 | onDeviceConnectionCallback = { gatt -> 366 | stopTimer(runnable) 367 | onSuccess(gatt) 368 | } 369 | device.connectGatt(context, false, gattHandler, BluetoothDevice.TRANSPORT_LE) 370 | } 371 | 372 | @SuppressLint("MissingPermission") 373 | private fun requestMTU(gatt: BluetoothGatt, mtu: Int, onSuccess: () -> Unit) { 374 | logger?.push(Log("Request MTU of $mtu")) 375 | val runnable = startTimer(timeoutPeriod, onTimeout = { /* let if fail, nothing more to do */}) 376 | onMTUChangedCallback = { 377 | stopTimer(runnable) 378 | onSuccess() 379 | } 380 | gatt.requestMtu(mtu) 381 | } 382 | 383 | @SuppressLint("MissingPermission") 384 | private fun searchForUltrablueService(gatt: BluetoothGatt, onServiceFound: (BluetoothGattService) -> Unit) { 385 | val runnable = startTimer(timeoutPeriod, onTimeout = { /* let if fail, nothing more to do */}) 386 | onServicesFoundCallback = { 387 | val ultrablueSvc = gatt.getService(ultrablueSvcUUID) 388 | logger?.push(CLog("Found Ultrablue service", true)) 389 | stopTimer(runnable) 390 | onServiceFound(ultrablueSvc) 391 | } 392 | logger?.push(Log("Searching for Ultrablue service")) 393 | gatt.discoverServices() 394 | } 395 | 396 | private fun startTimer(period: Long, onTimeout: () -> Unit) : Runnable { 397 | val timeoutCallback = Runnable { 398 | logger?.push(CLog("Timed out", false)) 399 | onTimeout() 400 | } 401 | timer.postDelayed(timeoutCallback, period) 402 | return timeoutCallback 403 | } 404 | 405 | private fun stopTimer(runnable: Runnable) { 406 | timer.removeCallbacks(runnable) 407 | } 408 | 409 | /* 410 | Reconstructs an Int from a little endian array of bytes. 411 | In particular, when reading the first packet of a BLE message. 412 | */ 413 | 414 | private fun byteArrayToInt(bytes: List) : Int { 415 | var result = 0 416 | for (i in bytes.indices) { 417 | var n = bytes[i].toInt() 418 | if (n < 0) { 419 | n += 256 420 | } 421 | result = result or (n shl 8 * i) 422 | } 423 | return result 424 | } 425 | 426 | /* 427 | Break an integer into it's little endian bytes representation. 428 | Useful to prepend the size of a message before sending. 429 | */ 430 | private fun intToByteArray(value: Int): ByteArray { 431 | var n = value 432 | val bytes = ByteArray(4) 433 | for (i in 0..3) { 434 | bytes[i] = (n and 0xffff).toByte() 435 | n = n ushr 8 436 | } 437 | return bytes 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/model/Addr.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue 2 | 3 | // Checks that the passed string is a well formatted MAC address. 4 | // It must have this format: "ff:ff:ff:ff:ff:ff", where 'ff' 5 | // represents a two digits hex value. The case doesn't matters. 6 | fun isMACAddressValid(data: String): Boolean { 7 | // 6 groups of 2 hex digits + 5 separator characters 8 | if (data.length != 17) { 9 | return false 10 | } 11 | // Check that once split, we have 6 groups 12 | val bytes = data.split(":") 13 | if (bytes.size != 6) { 14 | return false 15 | } 16 | // Check that each groups is composed of two hex digit 17 | for (byteStr in bytes) { 18 | if (byteStr.length != 2 || byteStr.toIntOrNull(16) == null) { 19 | return false 20 | } 21 | } 22 | return true 23 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/model/DeviceAdapter.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.* 5 | import android.widget.ImageButton 6 | import android.widget.TextView 7 | import androidx.recyclerview.widget.RecyclerView 8 | import fr.gouv.ssi.ultrablue.database.Device 9 | 10 | /* 11 | Allows to dispatch clicks on specific ViewHolder CardView, and handle them 12 | from the fragment that contains the recyclerview (DeviceListFragment). 13 | */ 14 | interface ItemClickListener { 15 | 16 | // Which section of the CardView has been clicked. 17 | enum class Target { 18 | ATTESTATION_BUTTON, TRASH_BUTTON, CARD_VIEW 19 | } 20 | 21 | fun onClick(id: Target, device: Device) 22 | } 23 | 24 | /* 25 | This Adapter manages a list of DeviceViewCards (defined in the res/layout folder). 26 | It derives from a RecyclerView Adapter for optimisation reasons. 27 | */ 28 | class DeviceAdapter(private val itemClickListener: ItemClickListener) : RecyclerView.Adapter() { 29 | // The list of registered devices to display. 30 | private var deviceList = emptyList() 31 | 32 | class ViewHolder(ItemView: View) : RecyclerView.ViewHolder(ItemView) { 33 | var nameTextView: TextView = itemView.findViewById(R.id.device_name) 34 | var addrTextView: TextView = itemView.findViewById(R.id.device_addr) 35 | val attestationButton: ImageButton = itemView.findViewById(R.id.attestation_button) 36 | var trashButton: ImageButton = itemView.findViewById(R.id.trash_button) 37 | } 38 | 39 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 40 | val view = LayoutInflater.from(parent.context) 41 | .inflate(R.layout.device_card_view, parent, false) 42 | return ViewHolder(view) 43 | } 44 | 45 | // Instantiate a specific DeviceViewCard. 46 | @SuppressLint("ClickableViewAccessibility") 47 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 48 | val device = deviceList[position] 49 | 50 | holder.nameTextView.text = device.name 51 | holder.addrTextView.text = device.addr 52 | 53 | holder.itemView.setOnClickListener { 54 | itemClickListener.onClick(ItemClickListener.Target.CARD_VIEW, device) 55 | } 56 | // Add a visual effect on each DeviceCardView tap, canceled on release. 57 | holder.itemView.setOnTouchListener {view, motionEvent -> 58 | val elevationDelta = 10 59 | when (motionEvent.action) { 60 | MotionEvent.ACTION_DOWN -> view.elevation -= elevationDelta 61 | MotionEvent.ACTION_UP -> { 62 | view.elevation += elevationDelta 63 | view.performClick() 64 | } 65 | MotionEvent.ACTION_CANCEL -> view.elevation += elevationDelta 66 | } 67 | true 68 | } 69 | 70 | holder.attestationButton.setOnClickListener { 71 | itemClickListener.onClick(ItemClickListener.Target.ATTESTATION_BUTTON, device) 72 | } 73 | // Change the button color on tap, and revert it on release. 74 | holder.attestationButton.setOnTouchListener {view, motionEvent -> 75 | when (motionEvent.action) { 76 | MotionEvent.ACTION_DOWN -> view.setBackgroundResource(R.drawable.ic_round_play_arrow_24_selected) 77 | MotionEvent.ACTION_UP -> { 78 | view.setBackgroundResource(R.drawable.ic_round_play_arrow_24) 79 | view.performClick() 80 | } 81 | MotionEvent.ACTION_CANCEL -> view.setBackgroundResource(R.drawable.ic_round_play_arrow_24) 82 | } 83 | true 84 | } 85 | 86 | holder.trashButton.setOnClickListener { 87 | itemClickListener.onClick(ItemClickListener.Target.TRASH_BUTTON, device) 88 | } 89 | // Change the button color on tap, and revert it on release. 90 | holder.trashButton.setOnTouchListener {view, motionEvent -> 91 | when (motionEvent.action) { 92 | MotionEvent.ACTION_DOWN -> view.setBackgroundResource(R.drawable.ic_baseline_delete_24_selected) 93 | MotionEvent.ACTION_UP -> { 94 | view.setBackgroundResource(R.drawable.ic_baseline_delete_24) 95 | view.performClick() 96 | } 97 | MotionEvent.ACTION_CANCEL -> view.setBackgroundResource(R.drawable.ic_baseline_delete_24) 98 | } 99 | true 100 | } 101 | } 102 | 103 | override fun getItemCount(): Int { 104 | return deviceList.size 105 | } 106 | 107 | @SuppressLint("NotifyDataSetChanged") 108 | fun setRegisteredDevices(devices: List) { 109 | this.deviceList = devices 110 | notifyDataSetChanged() 111 | } 112 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/model/Logger.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.model 2 | 3 | import android.text.Html 4 | import android.view.View 5 | import android.widget.ScrollView 6 | import android.widget.TextView 7 | import fr.gouv.ssi.ultrablue.MainActivity 8 | 9 | /* 10 | In this file, we implement four classes, allowing to display different kind 11 | of logs to the user, through a TextView. 12 | */ 13 | 14 | // The PLog class is used to represent the progress of something, 15 | // with a progress bar of this style: "[======> ] 32/88". 16 | class PLog(private val total: Int, private var barLength: Int = 25): Log(msg = "") { 17 | private var state: Int = 0 18 | 19 | fun updateProgress(state: Int) { 20 | this.state = if (state < total) { 21 | state 22 | } else { 23 | total 24 | } 25 | } 26 | 27 | override fun toString(): String { 28 | val percent = state.toFloat() / total.toFloat() 29 | val filledLength = (barLength * percent).toInt() 30 | return "[" + "=".repeat(filledLength) + ">" + " ".repeat(barLength - filledLength) + "] " + "$state/$total" 31 | } 32 | } 33 | 34 | // The CLog class instantiates a log for a completed action, that can be 35 | // successful of not. Takes the form of: "[ok] Lorem ipsum dolor sit amet". 36 | class CLog(private val msg: String, val success: Boolean, val fatal: Boolean = true): Log(msg = msg) { 37 | override fun toString(): String { 38 | return if (success) { 39 | "[ok]" 40 | } else { 41 | "[ko]" 42 | } + " " + msg 43 | } 44 | } 45 | 46 | // The Log class instantiates a log for a pending action. 47 | // Takes the form of: "Lorem ipsum dolor sit amet..." 48 | open class Log(private val msg: String) { 49 | override fun toString(): String { 50 | return " ".repeat(5) + msg + "..." 51 | } 52 | } 53 | 54 | /* 55 | The logger keeps an internal list of logs, which can be any of the three types 56 | of logs. When updated, either by pushing a new log, or updating a progress log, 57 | It updates the TextView content on the UI thread, so the user can see it 58 | immediately. 59 | When an unsuccessful CLog is pushed, the onError callback is called. 60 | */ 61 | class Logger(private var activity: MainActivity?, private var textView: TextView, private var scrollView: ScrollView? = null, private var onError: () -> Unit) { 62 | private var logs = listOf() 63 | 64 | fun push(log: Log) { 65 | logs = logs + log 66 | updateUI() 67 | if (log is CLog && !log.success && log.fatal) { 68 | onError() 69 | } 70 | } 71 | 72 | fun update(progress: Int) { 73 | if (logs.last() is PLog) { 74 | (logs.last() as PLog).updateProgress(progress) 75 | updateUI() 76 | } 77 | } 78 | 79 | fun reset() { 80 | this.logs = listOf() 81 | updateUI() 82 | } 83 | 84 | /* 85 | This is where we update the user interface so that the 86 | user see the logger updates. 87 | */ 88 | private fun updateUI() { 89 | activity?.runOnUiThread { 90 | textView.setText( 91 | Html.fromHtml(this.toString(), Html.FROM_HTML_MODE_COMPACT), 92 | TextView.BufferType.SPANNABLE 93 | ) 94 | scrollView?.let { 95 | it.post { it.fullScroll(View.FOCUS_DOWN) } 96 | } 97 | } 98 | } 99 | 100 | override fun toString(): String { 101 | return logs.joinToString(separator = "
", transform = { 102 | it.toString() 103 | }) 104 | } 105 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/java/fr/gouv/ssi/ultrablue/model/UltrablueProtocol.kt: -------------------------------------------------------------------------------- 1 | package fr.gouv.ssi.ultrablue.model 2 | 3 | import fr.gouv.ssi.ultrablue.MainActivity 4 | import fr.gouv.ssi.ultrablue.R 5 | import fr.gouv.ssi.ultrablue.database.Device 6 | import gomobile.Gomobile 7 | import kotlinx.serialization.* 8 | import kotlinx.serialization.cbor.ByteString 9 | import kotlinx.serialization.cbor.Cbor 10 | import java.security.SecureRandom 11 | 12 | @Serializable 13 | class RegistrationDataModel @OptIn(ExperimentalSerializationApi::class) constructor( 14 | @ByteString val N: ByteArray, 15 | val E: UInt, 16 | @ByteString val Cert: ByteArray, 17 | val PCRExtend: Boolean, 18 | ) 19 | 20 | @Serializable 21 | class EncryptedCredentialModel @OptIn(ExperimentalSerializationApi::class) constructor( 22 | @ByteString val Credential: ByteArray, 23 | @ByteString val Secret: ByteArray, 24 | ) 25 | 26 | @Serializable 27 | class ByteArrayModel @OptIn(ExperimentalSerializationApi::class) constructor( 28 | @ByteString val Bytes: ByteArray, 29 | ) 30 | 31 | @Serializable 32 | class AttestationResponse @OptIn(ExperimentalSerializationApi::class) constructor( 33 | val Err: Boolean, 34 | @ByteString val Secret: ByteArray, 35 | ) 36 | 37 | typealias ProtocolStep = Int 38 | 39 | const val REGISTRATION_DATA_READ = 0 40 | const val REGISTRATION_DATA_DECODE = 1 41 | const val AUTHENTICATION_READ = 2 42 | const val AUTHENTICATION = 3 43 | const val AK_READ = 4 44 | const val CREDENTIAL_ACTIVATION = 5 45 | const val CREDENTIAL_ACTIVATION_READ = 6 46 | const val CREDENTIAL_ACTIVATION_ASSERT = 7 47 | const val ATTESTATION_SEND_NONCE = 8 48 | const val ATTESTATION_READ = 9 49 | const val VERIFY_QUOTE = 10 50 | const val REPLAY_EVENT_LOG = 11 51 | const val PCRS_HANDLE = 12 52 | const val RESPONSE = 13 53 | 54 | /* 55 | UltrablueProtocol is the class that drives the client - server communication. 56 | It implements the protocol through read/write handlers and a state machine. 57 | The read/write operations are handled by the caller, so that this class isn't 58 | aware of the communication stack. It just calls the provided read/write methods 59 | whenever it needs to, and gets the results back in the onRead/onWrite handlers, to 60 | update the state machine, and resume the protocol. 61 | */ 62 | 63 | class UltrablueProtocol( 64 | private val activity: MainActivity, 65 | private var enroll: Boolean = false, 66 | private var device: Device, 67 | private val logger: Logger?, 68 | private val readMsg: (String) -> Unit, 69 | private var writeMsg: (String, ByteArray) -> Unit, 70 | private var onCompletion: (Boolean) -> Unit 71 | ) { 72 | private var state: ProtocolStep = if (enroll) { REGISTRATION_DATA_READ } else { AUTHENTICATION_READ } 73 | private var message = byteArrayOf() 74 | 75 | private val rand = SecureRandom() 76 | private var registrationData = RegistrationDataModel(device.ekn, device.eke.toUInt(), device.ekcert, device.secret.isNotEmpty()) // If enrolling a device, this field is uninitialized, but will be after EK_READ. 77 | private var credentialActivationSecret: ByteArray? = null 78 | private var encodedAttestationKey: ByteArray? = null 79 | private var encodedPlatformParameters: ByteArray? = null 80 | private val attestationNonce = ByteArray(16) 81 | private var encodedPCRs: ByteArray? = null 82 | private var attestationResponse: AttestationResponse? = null 83 | 84 | 85 | fun start() { 86 | resume() 87 | } 88 | 89 | /* 90 | Some steps ends with writeMsg or readMsg. It so, the state is automatically updated, and the 91 | protocol resumed. If not, we need to set the state, and call resume() manually. 92 | */ 93 | 94 | @OptIn(ExperimentalSerializationApi::class) 95 | private fun resume() { 96 | when (state) { 97 | REGISTRATION_DATA_READ -> readMsg(activity.getString(R.string.ek_pub_cert)) 98 | REGISTRATION_DATA_DECODE -> { 99 | registrationData = Cbor.decodeFromByteArray(message) 100 | state = AUTHENTICATION_READ 101 | resume() 102 | } 103 | AUTHENTICATION_READ -> readMsg(activity.getString(R.string.auth_nonce)) 104 | AUTHENTICATION -> { 105 | if (message.size != 24) { 106 | logger?.push(CLog("The Ultrablue server is running on enroll mode whereas an attestation was expected", false)) 107 | return 108 | } 109 | val authNonce = Cbor.decodeFromByteArray(message) 110 | //TODO: When encryption will be implemented, we'll need to decrypt the nonce here. 111 | val encodedAuthNonce = Cbor.encodeToByteArray(authNonce) 112 | writeMsg(activity.getString(R.string.decrypted_auth_nonce), encodedAuthNonce) 113 | } 114 | AK_READ -> readMsg(activity.getString(R.string.ak)) 115 | CREDENTIAL_ACTIVATION -> { 116 | encodedAttestationKey = message 117 | logger?.push(Log("Generating credential challenge")) 118 | try { 119 | val credentialBlob = Gomobile.makeCredential(registrationData.N, registrationData.E.toLong(), encodedAttestationKey) 120 | // We store the secret now to validate it later. 121 | credentialActivationSecret = credentialBlob.secret 122 | val encryptedCredential = EncryptedCredentialModel(credentialBlob.cred, credentialBlob.credSecret) 123 | val encodedCredential = Cbor.encodeToByteArray(encryptedCredential) 124 | logger?.push(CLog("Credential generated", true)) 125 | writeMsg(activity.getString(R.string.encrypted_cred), encodedCredential) 126 | } catch (e: Exception) { 127 | logger?.push(CLog("Failed to generate credential: ${e.message}", false)) 128 | } 129 | } 130 | CREDENTIAL_ACTIVATION_READ -> readMsg(activity.getString(R.string.decrypted_cred)) 131 | CREDENTIAL_ACTIVATION_ASSERT -> { 132 | val decryptedCredential = Cbor.decodeFromByteArray(message) 133 | logger?.push(Log("Comparing received credential")) 134 | if (decryptedCredential.Bytes.contentEquals(credentialActivationSecret)) { 135 | logger?.push(CLog("Credential matches the generated one", true)) 136 | state = ATTESTATION_SEND_NONCE 137 | resume() 138 | } else { 139 | logger?.push(CLog("Credential doesn't match the generated one", false)) 140 | } 141 | } 142 | ATTESTATION_SEND_NONCE -> { 143 | rand.nextBytes(attestationNonce) 144 | logger?.push(CLog("Generated anti replay nonce", true)) 145 | val encoded = Cbor.encodeToByteArray(ByteArrayModel(attestationNonce)) 146 | writeMsg("anti replay nonce", encoded) 147 | } 148 | ATTESTATION_READ -> readMsg(activity.getString(R.string.attestation_data)) 149 | VERIFY_QUOTE -> { 150 | encodedPlatformParameters = message 151 | logger?.push(Log("Verifying quotes signature")) 152 | try { 153 | Gomobile.checkQuotesSignature(encodedPlatformParameters, encodedAttestationKey, attestationNonce) 154 | logger?.push(CLog("Quotes signature are valid", true)) 155 | state = REPLAY_EVENT_LOG 156 | resume() 157 | } catch (e: Exception) { 158 | logger?.push(CLog("Error while verifying quote(s) signature: ${e.message}", false)) 159 | } 160 | } 161 | REPLAY_EVENT_LOG -> { 162 | logger?.push(Log("Replaying event log")) 163 | try { 164 | Gomobile.replayEventLog(encodedPlatformParameters) 165 | logger?.push(CLog("Event log has been replayed", true)) 166 | state = PCRS_HANDLE 167 | resume() 168 | } catch (e: Exception) { 169 | logger?.push(CLog("${e.message}", false)) 170 | attestationResponse = AttestationResponse(true, byteArrayOf()) 171 | state = RESPONSE 172 | resume() 173 | } 174 | } 175 | PCRS_HANDLE -> { 176 | try { 177 | logger?.push(Log("Getting PCRs")) 178 | encodedPCRs = Gomobile.getPCRs(encodedPlatformParameters).data 179 | logger?.push(CLog("Got PCRs", true)) 180 | } catch (e: Exception) { 181 | logger?.push(CLog("Error while getting PCRs: ${e.message}", false)) 182 | return 183 | } 184 | attestationResponse = if (enroll) { 185 | logger?.push(Log("Storing new attester entry")) 186 | registerDevice() 187 | AttestationResponse(false, device.secret) 188 | } else { 189 | logger?.push(Log("Comparing PCRs")) 190 | if (device.encodedPCRs.contentEquals(encodedPCRs)) { 191 | logger?.push(CLog("PCRs entries match the stored ones", true)) 192 | AttestationResponse(false, device.secret) 193 | } else { 194 | // TODO: Need deeper investigation to determine which PCR changed and why. 195 | logger?.push(CLog("PCRs don't match", false)) 196 | AttestationResponse(true, byteArrayOf()) 197 | } 198 | } 199 | state = RESPONSE 200 | resume() 201 | } 202 | RESPONSE -> { 203 | val encodedResponse = Cbor.encodeToByteArray(attestationResponse) 204 | writeMsg(activity.getString(R.string.attestation_response), encodedResponse) 205 | onCompletion(attestationResponse?.Err == false) 206 | } 207 | } 208 | } 209 | 210 | private fun registerDevice() { 211 | val secret = if (registrationData.PCRExtend) { 212 | ByteArray(16) 213 | } else { 214 | byteArrayOf() 215 | } 216 | rand.nextBytes(secret) 217 | 218 | device.name = "device" + device.uid 219 | device.ekn = registrationData.N 220 | device.eke = registrationData.E.toInt() 221 | device.ekcert = registrationData.Cert 222 | device.encodedPCRs = encodedPCRs!! 223 | device.secret = secret 224 | logger?.push(Log("Registering device")) 225 | activity.viewModel.insert(device) 226 | logger?.push(CLog("Device has been registered", true)) 227 | } 228 | 229 | fun onMessageRead(message: ByteArray) { 230 | state += 1 231 | this.message = message 232 | resume() 233 | } 234 | 235 | fun onMessageWrite() { 236 | state += 1 237 | resume() 238 | } 239 | 240 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_baseline_add_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_baseline_delete_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_baseline_delete_24_selected.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_baseline_edit_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_round_play_arrow_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/drawable/ic_round_play_arrow_24_selected.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 20 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/layout/device_card_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 20 | 21 | 31 | 32 | 41 | 42 | 52 | 53 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/layout/fragment_device.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 11 | 12 | 24 | 25 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/layout/fragment_device_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/layout/fragment_protocol.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/menu/action_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 17 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 17 | 20 | 21 | 26 | 31 | 32 | 37 | 40 | 41 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Ultrablue 3 | Enroll 4 | Edit 5 | Scan the registration QR code from ultrablue-server application 6 | You must grant the camera permission for ultrablue.\n\nultrablue > App info > Permissions 7 | Missing camera permission 8 | Failed to read the QR code 9 | An error occurred while reading the QR code. 10 | Oops, this QR code is not recognized. Please scan a valid registration QR code 11 | Invalid QR code 12 | 13 | Your devices 14 | Protocol 15 | Device 16 | Attestation button 17 | Delete button 18 | Address: 19 | 20 | Choose new device name 21 | Delete 22 | "Are you sure you want to delete %s\nThis action is irreversible." 23 | 24 | Delete 25 | Cancel 26 | 27 | 28 | EkPub and EKCert 29 | authentication nonce 30 | decrypted authentication nonce 31 | attestation key 32 | encrypted credential 33 | decrypted credential 34 | anti replay nonce 35 | attestation data 36 | attestation response 37 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | id 'com.android.application' version '7.2.1' apply false 4 | id 'com.android.library' version '7.2.1' apply false 5 | id 'org.jetbrains.kotlin.android' version '1.7.10' apply false 6 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.10' 7 | } 8 | 9 | task clean(type: Delete) { 10 | delete rootProject.buildDir 11 | } -------------------------------------------------------------------------------- /clients/Android/ultrablue/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /clients/Android/ultrablue/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/clients/Android/ultrablue/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /clients/Android/ultrablue/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jul 11 10:58:21 CEST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /clients/Android/ultrablue/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name = "ultrablue" 16 | include ':app' 17 | -------------------------------------------------------------------------------- /clients/README.md: -------------------------------------------------------------------------------- 1 | # Ultrablue clients 2 | 3 | * [Android client](Android) 4 | * [IOS client](ios) 5 | * [Go-mobile helper library](go-mobile), used by both clients to perform some TPM-related operations. 6 | -------------------------------------------------------------------------------- /clients/go-mobile/.gitignore: -------------------------------------------------------------------------------- 1 | gomobile.aar 2 | gomobile-sources.jar 3 | -------------------------------------------------------------------------------- /clients/go-mobile/README.md: -------------------------------------------------------------------------------- 1 | # go-mobile library for Ultrablue clients 2 | 3 | [go-mobile](https://github.com/golang/go/wiki/Mobile) is a set of tools for using Go on mobile platforms, in order to embeed it on smartphone applications. 4 | 5 | Ultrablue uses it because some TPM-related libraries it needs aren't available on IOS/Android's native plateform. 6 | 7 | ## Quickstart 8 | 9 | There is an automated script to install required dependencies and build Ultrablue's go-mobile library: 10 | 11 | ``` 12 | ./create_archive.sh 13 | ``` 14 | 15 | ## Step-by-step instructions 16 | 17 | In case the automated script doesn't work, you can try troubleshooting it as follows: 18 | 19 | **Install gomobile:** 20 | ``` 21 | go install golang.org/x/mobile/cmd/gobind@latest 22 | go install golang.org/x/mobile/cmd/gomobile@latest 23 | go mod download golang.org/x/mobile 24 | go get golang.org/x/mobile/bind 25 | ``` 26 | Make sure it is on your `PATH` once installed. 27 | 28 | **Set needed environment variables:** 29 | ``` 30 | export ANDROID_HOME=/path/to/Sdk 31 | export ANDROID_NDK_HOME=/path/to/ndk-bundle 32 | ``` 33 | 34 | Make sure you install installed both Android Studio (for the SDK) and [the Android NDK](https://developer.android.com/ndk/) (from Android Studio). 35 | 36 | **Compile your code to the desired architecture:** 37 | ``` 38 | gomobile bind -target=ios -v . #Note that Xcode is required 39 | ``` 40 | or 41 | ``` 42 | gomobile bind -target=android -v . 43 | ``` 44 | This will produce an `.aar` archive for Android, or a `.XCFramework` for IOS, please refer to specific documentation to include those in your project. 45 | 46 | ## Code restrictions 47 | 48 | The following points are rather a collection of advice I wish I had known when I first used gomobile than strong requirements. 49 | 50 | - Functions must return pointers 51 | - You can return structures 52 | - You can't return nested structures 53 | - You can't return interface types / neither structures with interface fields 54 | - Byte arrays are'nt handled very well, thus prefer a structure with a unique byte array field 55 | - error is the only type you can return without a pointer to it. If an error is returned, it will raise an exception that the caller will be able to handle, not returning him an error value. 56 | - To make a structure field available to the caller, you must export it as you would do for any go package. 57 | 58 | For more reliable information, please refer to [the gomobile documentation](https://github.com/golang/go/wiki/Mobile). 59 | 60 | -------------------------------------------------------------------------------- /clients/go-mobile/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -i *.aar *.jar 4 | -------------------------------------------------------------------------------- /clients/go-mobile/client.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /* 5 | This file contains go functions that are meant to be compiled 6 | for a mobile architecture, and binded to its native language. 7 | This way, those functions are available while developing 8 | native applicatioins (IOS/Android). 9 | 10 | We embeed go in mobile applications because some libraries 11 | (mainly go-attestation) don't exist in higher level languages, 12 | and it would take too much time to reimplement them. 13 | */ 14 | 15 | package gomobile 16 | 17 | import ( 18 | "crypto/rsa" 19 | "math/big" 20 | "errors" 21 | 22 | "github.com/fxamacker/cbor/v2" 23 | "github.com/google/go-attestation/attest" 24 | ) 25 | 26 | /* 27 | CredentialBlob is a structure used during the 28 | credential activation process. It packs all 29 | return values of the ActivationParameters.generate 30 | method from the attest package. 31 | */ 32 | type CredentialBlob struct { 33 | Secret []byte 34 | Cred []byte 35 | CredSecret []byte 36 | } 37 | 38 | /* 39 | PCRs are returned encoded to CBOR because 40 | gobind can't return complex types such as 41 | arrays. 42 | */ 43 | type EncodedPCRs struct { 44 | Data []byte 45 | } 46 | 47 | /* 48 | buildRSAPublicKey rebuilds a crypto.PublicKey from raw public 49 | bytes and exponent of an RSA key. 50 | */ 51 | func buildRSAPublicKey(publicBytes []byte, exponent int) rsa.PublicKey { 52 | public := big.NewInt(0).SetBytes(publicBytes) 53 | key := rsa.PublicKey{ 54 | N: public, 55 | E: exponent, 56 | } 57 | return key 58 | } 59 | 60 | /* 61 | MakeCredential generates a challenge used to assert that 62 | the attestation key @encodedap has been generated on the 63 | same TPM that the endorsement key @ekn + @eke. 64 | */ 65 | func MakeCredential(ekn []byte, eke int, encodedap []byte) (*CredentialBlob, error) { 66 | var ap attest.AttestationParameters 67 | 68 | err := cbor.Unmarshal(encodedap, &ap) 69 | if err != nil { 70 | return nil, err 71 | } 72 | ekPub := buildRSAPublicKey(ekn, eke) 73 | activationParams := attest.ActivationParameters{ 74 | TPMVersion: attest.TPMVersion20, 75 | EK: &ekPub, 76 | AK: ap, 77 | } 78 | s, ec, err := activationParams.Generate() 79 | if err != nil { 80 | return nil, err 81 | } 82 | return &CredentialBlob{s, ec.Credential, ec.Secret}, nil 83 | } 84 | 85 | /* 86 | CheckQuotesSignature verifies that all quotes coming from 87 | the attestation data in @encodedpp are signed by the 88 | attestation key @encodedak, and contains the anti replay 89 | nonce. 90 | 91 | It also asserts that the final PCRs values from the attestation 92 | data matches the quotes ones. 93 | */ 94 | func CheckQuotesSignature(encodedap, encodedpp, nonce []byte) error { 95 | var ap attest.AttestationParameters 96 | var pp attest.PlatformParameters 97 | 98 | // Decoding CBOR parameters 99 | if err := cbor.Unmarshal(encodedap, &ap); err != nil { 100 | return err 101 | } 102 | if err := cbor.Unmarshal(encodedpp, &pp); err != nil { 103 | return err 104 | } 105 | 106 | // Verify attestation quotes 107 | akpub, err := attest.ParseAKPublic(attest.TPMVersion20, ap.Public) 108 | if err != nil { 109 | return err 110 | } 111 | for _, quote := range pp.Quotes { 112 | if err := akpub.Verify(quote, pp.PCRs, nonce); err != nil { 113 | return err 114 | } 115 | } 116 | return nil 117 | } 118 | 119 | /* 120 | ReplayEventLog verifies that the eventlog from @encodedpp 121 | matches the final PCRs values (that were previously checked 122 | against the quotes ones). 123 | */ 124 | func ReplayEventLog(encodedpp []byte) error { 125 | var pp attest.PlatformParameters 126 | 127 | if err := cbor.Unmarshal(encodedpp, &pp); err != nil { 128 | return err 129 | } 130 | el, err := attest.ParseEventLog(pp.EventLog) 131 | if err != nil { 132 | return err 133 | } 134 | _, err = el.Verify(pp.PCRs) 135 | if rErr, isReplayErr := err.(attest.ReplayError); isReplayErr { 136 | return errors.New(rErr.Error()) 137 | } 138 | return err 139 | } 140 | 141 | /* 142 | GetPCRs extracts, encodes and returns PCRs from the 143 | attestation data @encodedpp. 144 | */ 145 | func GetPCRs(encodedpp []byte) (*EncodedPCRs, error) { 146 | var pp attest.PlatformParameters 147 | 148 | if err := cbor.Unmarshal(encodedpp, &pp); err != nil { 149 | return nil, err 150 | } 151 | ep, err := cbor.Marshal(pp.PCRs) 152 | if err != nil { 153 | return nil, err 154 | } 155 | return &EncodedPCRs {ep}, nil 156 | } 157 | -------------------------------------------------------------------------------- /clients/go-mobile/create_archive.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | go install golang.org/x/mobile/cmd/gobind@latest 4 | go install golang.org/x/mobile/cmd/gomobile@latest 5 | 6 | go mod download golang.org/x/mobile 7 | go get golang.org/x/mobile/bind 8 | 9 | # Force Android API to >= 19 to work around bug with newer SDK: 10 | # https://github.com/golang/go/issues/52470#issuecomment-1203874724 11 | gomobile bind -target=android -androidapi 19 -v . 12 | 13 | TARGET="../Android/ultrablue/app/libs" 14 | mkdir -p $TARGET 15 | echo "Copying gomobile.aar to $TARGET" 16 | cp gomobile.aar "$TARGET" 17 | -------------------------------------------------------------------------------- /clients/go-mobile/go.mod: -------------------------------------------------------------------------------- 1 | module gomobile 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/fxamacker/cbor/v2 v2.4.0 // indirect 7 | github.com/google/certificate-transparency-go v1.1.1 // indirect 8 | github.com/google/go-attestation v0.4.3 // indirect 9 | github.com/google/go-tpm v0.3.3 // indirect 10 | github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad // indirect 11 | github.com/x448/float16 v0.8.4 // indirect 12 | golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b // indirect 13 | golang.org/x/mobile v0.0.0-20220722155234-aaac322e2105 // indirect 14 | golang.org/x/mod v0.4.2 // indirect 15 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect 16 | golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098 // indirect 17 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /clients/ios/README.md: -------------------------------------------------------------------------------- 1 | # Ultrablue IOS client 2 | 3 | The IOS client has not been merged yet, see [PR#14](https://github.com/ANSSI-FR/ultrablue/pull/14). 4 | -------------------------------------------------------------------------------- /doc/presentations/ultrablue-buckwell-kerneis-bouchinet-osfc-2022.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ANSSI-FR/ultrablue/6de04af6e353e38c030539c5678e5918f64be37e/doc/presentations/ultrablue-buckwell-kerneis-bouchinet-osfc-2022.pdf -------------------------------------------------------------------------------- /doc/protocol/characteristics_protocol.txt: -------------------------------------------------------------------------------- 1 | // participants 2 | participantgroup #lightblue **Attester** 3 | participant TPM 4 | participant CPU 5 | end 6 | participant Verifier 7 | 8 | 9 | // Registration 10 | TPM<->CPU: Generate AES 128 bit key and IV 11 | CPU <<-#gray>> Verifier: AES key, IV and MAC address 12 | group #red registrationChr 13 | CPU <->TPM: createek() 14 | CPU-#0000ff:1>Verifier: EkPub, EkCert 15 | parallel 16 | Verifier -> Verifier: Store new Attester object\n(MAC address, AES key, EkPub, EkCert) 17 | CPU->CPU: Store new Verifier object\n (MAC address, AES key) 18 | parallel off 19 | end 20 | 21 | // Attestation 22 | group #red authenticationChr 23 | CPU<-CPU: Generate IV + nonce 24 | CPU-#0000ff:1>Verifier: IV, encrypted nonce 25 | Verifier-#0000ff:1>CPU: decrypted nonce 26 | CPU->CPU: nonce comparison 27 | end 28 | group #red credActivationChr 29 | CPU<->TPM: tpm2_createak() 30 | CPU-#0000ff:1>Verifier: AkName 31 | Verifier -> Verifier: Generate credential secret\ntpm2_makecredential(secret, AkName, EkPub) 32 | Verifier-#0000ff:1>CPU: credential_blob 33 | CPU<->TPM: tpm2_activatecredential(credential_blob) 34 | CPU-#0000ff:1>Verifier: decrypted credential secret 35 | end 36 | group #red attestationChr 37 | 38 | Verifier->Verifier: Generate anti replay nonce 39 | Verifier-#0000ff:1>CPU: nonce 40 | CPU<->TPM:tpm2_quote() 41 | CPU-#0000ff:1>Verifier: secret / quotes / event_log 42 | end 43 | group #red responseChr 44 | Verifier->Verifier: 1. nonce comparison\n2. Quotes signature verification\n3. Event log replay\n4. PCR digest comparisons\n5. Security policy 45 | Verifier--#0000ff:1>CPU: Attestation response 46 | end 47 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | ultrablue-server 2 | testbed/mkosi/ 3 | testbed/mkosi.output/ 4 | testbed/mkosi.extra/ 5 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Ultrablue server 2 | 3 | ``` 4 | go build 5 | ./ultrablue-server 6 | ``` 7 | 8 | You may have to run the server as root to access bluetooth and TPM devices. 9 | 10 | ## Usage 11 | 12 | ``` 13 | --enroll: 14 | When used, the server will start in enroll mode, 15 | needed to register a new verifier with the client app. 16 | Otherwise, the server will start in attestation mode. 17 | 18 | --loglevel: 19 | The loglevel flag takes an integer parameter between 0 and 3. 20 | It indicates the verbosity level of the server. 21 | 0 stands for no log, 2 for maximum output. 22 | 23 | --mtu: 24 | Sets the MTU (Maximum transmission Unit) size for the BLE packets. 25 | Must be between 20 and 500 to be effective. 26 | 27 | --pcr-extend: 28 | Extends the 9th PCR with the verifier secret on attestation success. 29 | ``` 30 | 31 | ## Testing 32 | 33 | ``` 34 | # GOTMPDIR is only necessary if /tmp is set as noexec. 35 | GOTMPDIR=$XDG_RUNTIME_DIR go test 36 | ``` 37 | 38 | There is also a [testbed to do end-to-end testing](testbed/). 39 | ## Configuration files 40 | 41 | Ultrablue-server itself has no configuration file. 42 | 43 | Sample integration files for systemd and Dracut are provided in the `unit/` and 44 | `dracut/` directories. See [the testbed VM](testbed/) for example usage of those. 45 | 46 | 47 | --- 48 | ⚠️ The server is only Linux compatible for now. 49 | -------------------------------------------------------------------------------- /server/characteristic.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | /* 5 | The functions in this file implement an abstraction layer 6 | to send and receive messages on a BLE channel. 7 | 8 | The interface with the rest of the application is a 9 | bidirectionnal go channel carrying byte slices. 10 | Each send/receive operation is made by writing/reading 11 | on the go channel, and is blocking. 12 | Acknowledgment of successful write operation is notified 13 | with a nil message written back on the go channel. 14 | 15 | On the BLE channel, messages are chunked in packets of 16 | MTU max size, because there's no native concept of packet 17 | fragmentation in BLE. 18 | The first packet of each message starts with the size of the 19 | message, encoded on four bytes, in little endian, followed 20 | by the raw message bytes. 21 | There's no prefix in the following chunks. 22 | */ 23 | 24 | package main 25 | 26 | import ( 27 | "errors" 28 | 29 | "encoding/binary" 30 | "github.com/go-ble/ble" 31 | "github.com/sirupsen/logrus" 32 | ) 33 | 34 | const DEFAULT_MTU = 20 35 | 36 | /* 37 | terminateConnection is an error handling helper: 38 | In case of error in the transport layer, we need to 39 | close the channel if the characteristic is expected 40 | to write on it, to signal the error to the reader on the 41 | other side. If the characteristic is instead expected to read from 42 | the channel, it must not be closed, thus nil must be given as parameter. 43 | The connection is closed regardless, disconnecting the client, 44 | but not terminating the program. This means that the client 45 | can reconnect later to retry the attestation. 46 | */ 47 | func terminateConnection(conn ble.Conn, ch chan []byte) { 48 | if ch != nil { 49 | close(ch) 50 | } 51 | conn.Close() 52 | } 53 | 54 | /* 55 | sendBLEPacket performs a partial write of @msg of at most @mtu bytes, 56 | starting at offset @off, to the @rsp BLE channel. 57 | It updates @off to point to the first unwritten byte. 58 | If @off is equal to 0, the size of the full message is written as 59 | prefix in little endian. 60 | 61 | If the message is not fully sent during a call to sendBLEPacket, 62 | the remote device should read the characteristic again, to get 63 | the remaining bytes. 64 | */ 65 | func sendBLEPacket(off *int, msg []byte, mtu int, rsp ble.ResponseWriter) error { 66 | var packet []byte 67 | var copied, msgoff int 68 | 69 | if *off >= len(msg) { 70 | return nil 71 | } 72 | 73 | if len(msg) >= 1<<32 { 74 | logrus.Fatal("Message too big:", len(msg)) 75 | } 76 | 77 | packet = make([]byte, mtu) 78 | if *off == 0 { 79 | binary.LittleEndian.PutUint32(packet[:], uint32(len(msg))) 80 | msgoff = 4 81 | } 82 | copied = copy(packet[msgoff:mtu], msg[*off:]) 83 | _, err := rsp.Write(packet[:copied+msgoff]) 84 | if err != nil { 85 | return err 86 | } 87 | *off += copied 88 | return nil 89 | } 90 | 91 | /* 92 | recvBLEPacket reads a BLE packet in the @req buffer, and appends it 93 | in the final @out message. 94 | If *@size is zero (meaning it is the first packet's message), 95 | recvBLEPacket first reads the size, prefix and update the size. 96 | Note that if a protocol message is fixed length, and the sender don't 97 | put the size at the message start, *@size must be set manually before 98 | the first recvBLEPacket call. 99 | */ 100 | func recvBLEPacket(out *[]byte, size *int, req ble.Request) error { 101 | var offset int 102 | 103 | if *size == 0 && len(req.Data()) >= 4 { 104 | *size = int(binary.LittleEndian.Uint32(req.Data()[0:4])) 105 | offset = 4 106 | } 107 | if *size < 0 { 108 | return errors.New("Invalid packet size prefix") 109 | } 110 | *out = append(*out, req.Data()[offset:]...) 111 | return nil 112 | } 113 | 114 | /* 115 | UltrablueChr is the only characteristic exposed by the ultrablue server. 116 | The client server interactions are made through the HandleRead and HandleWrite 117 | callbacks. 118 | In order to abstract chunking, those callbacks will store / chunk / rebuild the 119 | messages internally, and send / receive full messages in an internal channel 120 | when some message is fully available. 121 | This makes the server able to interact with the client easily through channels. 122 | */ 123 | func UltrablueChr(mtu int) *ble.Characteristic { 124 | chr := ble.NewCharacteristic(ultrablueChrUUID) 125 | 126 | if mtu < 20 || mtu > 500 { 127 | mtu = DEFAULT_MTU 128 | } 129 | 130 | // HandleRead is a callback triggered when a client reads on the characteristic, 131 | // which corresponds to the server sending data (Write operation) 132 | chr.HandleRead(ble.ReadHandlerFunc(func(req ble.Request, rsp ble.ResponseWriter) { 133 | logrus.Tracef("%s - HandleRead", ultrablueChrUUID.String()) 134 | 135 | var state = getConnectionState(req.Conn()) 136 | 137 | if state.operation != Write { 138 | if err := state.StartOperation(Write); err != nil { 139 | terminateConnection(req.Conn(), state.ch) 140 | return 141 | } 142 | var ok bool 143 | state.Buf, ok = <-state.ch 144 | if !ok { 145 | // The channel has already been closed. 146 | terminateConnection(req.Conn(), nil) 147 | return 148 | } 149 | logrus.Tracef("New message to send:\nlen: %d, preview: %v\n", len(state.Buf), state.Buf) 150 | } 151 | err := sendBLEPacket(&state.Offset, state.Buf, mtu, rsp) 152 | if err != nil { 153 | logrus.Error(err) 154 | terminateConnection(req.Conn(), state.ch) 155 | return 156 | } 157 | logrus.Tracef("Sent %d/%d bytes - %d%%", state.Offset, len(state.Buf), int(100.0*state.Offset/len(state.Buf))) 158 | if state.isComplete() { 159 | if err := state.EndOperation(); err != nil { 160 | logrus.Error(err) 161 | terminateConnection(req.Conn(), state.ch) 162 | return 163 | } 164 | // Sending nil to the channel notifies the caller that the operation has ended. 165 | state.ch <- nil 166 | } 167 | })) 168 | 169 | // HandleWrite is a callback triggered when a client writes on the characteristic, 170 | // which corresponds to the server expecting data (Read operation) 171 | chr.HandleWrite(ble.WriteHandlerFunc(func(req ble.Request, rsp ble.ResponseWriter) { 172 | logrus.Tracef("%s - HandleWrite", ultrablueChrUUID.String()) 173 | 174 | var state = getConnectionState(req.Conn()) 175 | 176 | if state.operation != Read { 177 | if err := state.StartOperation(Read); err != nil { 178 | logrus.Error(err) 179 | terminateConnection(req.Conn(), state.ch) 180 | return 181 | } 182 | } 183 | err := recvBLEPacket(&state.Buf, &state.Msglen, req) 184 | if err != nil { 185 | terminateConnection(req.Conn(), state.ch) 186 | return 187 | } 188 | if state.isComplete() { 189 | logrus.Tracef("Received a message:\nlen:%d, preview: %v\n", state.Msglen, state.Buf) 190 | buf := state.Buf 191 | if err := state.EndOperation(); err != nil { 192 | logrus.Error(err) 193 | terminateConnection(req.Conn(), state.ch) 194 | return 195 | } 196 | state.ch <- buf 197 | } 198 | })) 199 | 200 | return chr 201 | } 202 | -------------------------------------------------------------------------------- /server/characteristic_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "encoding/binary" 9 | "math/rand" 10 | "testing" 11 | "time" 12 | 13 | "github.com/go-ble/ble" 14 | ) 15 | 16 | func TestSendBLEPacket_OneShot(t *testing.T) { 17 | var cases = []struct { 18 | offset int 19 | msglen int 20 | mtu int 21 | expected bool 22 | name string 23 | }{ 24 | {0, 0, 20, true, "Null sized message"}, 25 | {0, 42, 20, false, "Message length > MTU"}, 26 | {0, 30, 45, true, "Message length < MTU"}, 27 | {0, 42, 42, false, "Message length == MTU"}, 28 | {0, 42, 44, false, "Message length + prefix > MTU"}, 29 | {20, 41, 20, false, "Message length - offset > MTU"}, 30 | {42, 50, 20, true, "Message length - offset < MTU"}, 31 | {20, 40, 20, true, "Message length - offset == MTU"}, 32 | } 33 | 34 | for _, c := range cases { 35 | var responseBuffer = bytes.NewBuffer(make([]byte, 0, 10000)) 36 | var rw = ble.NewResponseWriter(responseBuffer) 37 | 38 | var msg = make([]byte, c.msglen) 39 | rand.Read(msg) 40 | 41 | err := sendBLEPacket(&c.offset, msg, c.mtu, rw) 42 | if err != nil { 43 | t.Errorf("[%s]: %s", c.name, err) 44 | } 45 | var complete bool = (c.offset == c.msglen) 46 | if complete != c.expected { 47 | t.Errorf("[%s]: expected: %t, got: %t (msg length: %d, offset: %d, MTU: %d)", c.name, c.expected, complete, c.msglen, c.offset, c.mtu) 48 | } 49 | } 50 | } 51 | 52 | func TestSendBLEPacket_RealCases(t *testing.T) { 53 | var cases = []struct { 54 | msglen int 55 | mtu int 56 | expected int 57 | name string 58 | }{ 59 | {0, 20, 1, "Null sized message"}, 60 | {12, 20, 1, "Message length < MTU"}, 61 | {20, 20, 2, "Message length == MTU"}, 62 | {80, 20, 5, "Message length == MTU * 4"}, 63 | } 64 | 65 | for _, c := range cases { 66 | var responseBuffer = bytes.NewBuffer(make([]byte, 0, 10000)) 67 | var rw = ble.NewResponseWriter(responseBuffer) 68 | 69 | var msg = make([]byte, c.msglen) 70 | var offset, result int 71 | rand.Read(msg) 72 | 73 | for true { 74 | err := sendBLEPacket(&offset, msg, c.mtu, rw) 75 | result += 1 76 | if err != nil { 77 | t.Errorf("[%s]: %s", c.name, err) 78 | } 79 | if c.msglen == offset { 80 | break 81 | } 82 | } 83 | 84 | if result != c.expected { 85 | t.Errorf("[%s]: expected exactly %d calls to send %d bytes with mtu %d, got %d", c.name, c.expected, c.msglen, c.mtu, result) 86 | } 87 | if c.msglen > 0 { 88 | if bytes.Compare(responseBuffer.Bytes()[4:], msg) != 0 { 89 | t.Errorf("[%s]: sended bytes differs from the original message:\nexpected: %x\ngot: %x", c.name, msg, responseBuffer.Bytes()[4:]) 90 | } 91 | prefix := int(binary.LittleEndian.Uint32(responseBuffer.Bytes()[:4])) 92 | if prefix != c.msglen { 93 | t.Errorf("[%s]: message is %d bytes long, but the prefix indicates %d", c.name, c.msglen, prefix) 94 | } 95 | } 96 | } 97 | } 98 | 99 | func TestRecvBLEPacket_OneShot(t *testing.T) { 100 | var cases = []struct { 101 | readlen int 102 | preset bool 103 | msglen int 104 | expected bool 105 | desc string 106 | }{ 107 | {20, true, 20, true, "preset readlen - small mtu"}, 108 | {500, true, 500, true, "preset readlen - medium mtu"}, 109 | {1000, true, 1000, true, "preset readlen - big mtu"}, 110 | {35, true, 50, false, "preset readlen - msg length > msglen"}, 111 | {50, true, 35, false, "preset readlen - msg length < msglen"}, 112 | 113 | {20, false, 20, true, "no readlen - small mtu"}, 114 | {500, false, 500, true, "no readlen - medium mtu"}, 115 | {1000, false, 1000, true, "no readlen - big mtu"}, 116 | {35, false, 50, false, "no readlen - msg length > msglen"}, 117 | {50, false, 35, false, "preset readlen - msg length < msglen"}, 118 | {123431243, false, 23, false, "no readlen - msg length < msglen"}, 119 | {0, false, 30, false, "no readlen - forgot readlen header"}, 120 | } 121 | 122 | for _, c := range cases { 123 | var msg = make([]byte, c.msglen) 124 | if c.preset == false { 125 | header := make([]byte, 4) 126 | binary.LittleEndian.PutUint32(header[:], uint32(c.readlen)) 127 | msg = append(header, msg...) 128 | c.readlen = 0 129 | } 130 | var req = ble.NewRequest(nil, msg, 0) 131 | var buf = make([]byte, 0) 132 | 133 | err := recvBLEPacket(&buf, &c.readlen, req) 134 | if err != nil { 135 | t.Error(err) 136 | } 137 | var complete bool = len(buf) == c.readlen 138 | if complete != c.expected { 139 | t.Errorf("[%s] - read: %d, msglen: %d | expected: %t, got %t", c.desc, c.readlen, c.msglen, c.expected, complete) 140 | } 141 | if len(buf) != c.msglen { 142 | t.Errorf("[%s] - expected msg of length %d, got %d", c.desc, c.msglen, len(buf)) 143 | } 144 | } 145 | } 146 | 147 | func TestRecvBLEPacket_With_SendBLEPacket(t *testing.T) { 148 | rand.Seed(time.Now().UnixNano()) 149 | for i := 0; i < 100; i++ { 150 | // Set a random MTU, the sendBLEPacket function should fix it if invalid 151 | var mtu = rand.Intn(500-20) + 20 152 | 153 | // Create a random message of a random size 154 | var msglen = rand.Intn(10000) 155 | var msg = make([]byte, msglen) 156 | rand.Read(msg) 157 | 158 | // Create the buffer to read the message in 159 | var out []byte 160 | 161 | // Function arguments, return values and helper variables 162 | var completewr, completerd bool 163 | var off, ml, counter int 164 | 165 | // While the whole message has not been written, alternate between 166 | // sendBLEPacket and recvBLEPacket, to simulate a real interaction. 167 | for counter = 0; completewr == false; counter++ { 168 | requestBuffer := bytes.NewBuffer(make([]byte, 0, mtu)) 169 | rw := ble.NewResponseWriter(requestBuffer) 170 | err := sendBLEPacket(&off, msg, mtu, rw) 171 | if err != nil { 172 | t.Fatal(err) 173 | } 174 | completewr = off == msglen 175 | err = recvBLEPacket(&out, &ml, ble.NewRequest(nil, requestBuffer.Bytes(), 0)) 176 | completerd = len(out) == msglen 177 | } 178 | 179 | // Test read completeness 180 | if completerd == false { 181 | t.Error("The write operation has been completed, but the read operation has not") 182 | } 183 | 184 | // Test received message length against sended one 185 | if len(out) != len(msg) { 186 | t.Errorf("The sent message is of length %d, but the received message is of length %d.", len(msg), len(out)) 187 | } 188 | 189 | // Test received message data against sended one 190 | if bytes.Compare(msg, out) != 0 { 191 | t.Error("The sent message data differs from the received one.") 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /server/dracut/90crypttab.conf: -------------------------------------------------------------------------------- 1 | install_items="/etc/crypttab" 2 | -------------------------------------------------------------------------------- /server/dracut/90ultrablue/module-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # SPDX-FileCopyrightText: 2022 ANSSI 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | # Prerequisite check(s) for module. 7 | check() { 8 | # If the binary(s) requirements are not fulfilled the module can't be installed. 9 | require_binaries ultrablue-server || return 1 10 | return 255 11 | } 12 | 13 | # Module dependency requirements. 14 | depends() { 15 | # This module has external dependency on other module(s). 16 | echo bluetooth tpm2-tss 17 | # Return 0 to include the dependent module(s) in the initramfs. 18 | return 0 19 | } 20 | 21 | # Install the required file(s) and directories for the module in the initramfs. 22 | install() { 23 | inst_multiple -o \ 24 | /usr/bin/ultrablue-server \ 25 | "${systemdsystemunitdir}"/ultrablue-server.service 26 | 27 | $SYSTEMCTL -q --root "$initdir" enable ultrablue-server.service 28 | } 29 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module ultrablue-server 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/fxamacker/cbor/v2 v2.4.0 7 | github.com/go-ble/ble v0.0.0-20220207185428-60d1eecf2633 8 | github.com/google/go-attestation v0.4.3 9 | github.com/google/go-tpm v0.3.3 10 | github.com/google/uuid v1.1.1 11 | github.com/sirupsen/logrus v1.5.0 12 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 13 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 14 | ) 15 | 16 | require ( 17 | github.com/google/certificate-transparency-go v1.1.1 // indirect 18 | github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad // indirect 19 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect 20 | github.com/mattn/go-colorable v0.1.6 // indirect 21 | github.com/mattn/go-isatty v0.0.12 // indirect 22 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect 23 | github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab // indirect 24 | github.com/pkg/errors v0.8.1 // indirect 25 | github.com/x448/float16 v0.8.4 // indirect 26 | golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b // indirect 27 | golang.org/x/sys v0.0.0-20211204120058-94396e421777 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // ultrablue-server 5 | package main 6 | 7 | import ( 8 | "context" 9 | "flag" 10 | "fmt" 11 | 12 | "github.com/go-ble/ble" 13 | "github.com/go-ble/ble/linux" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // Command line arguments - Global variables 18 | var ( 19 | enroll = flag.Bool("enroll", false, "Must be set for a first time attestation (known as the enrollment)") 20 | loglevel = flag.Int("loglevel", 1, "Indicates the level of logging, 0 is the minimum, 3 is the maximum") 21 | mtu = flag.Int("mtu", 500, "Set a custom MTU, which is basically the max size of the BLE packets") 22 | pcrextend = flag.Bool("pcr-extend", false, "Extend the 9th PCR with the verifier secret on attestation success") 23 | withpin = flag.Bool("with-pin", false, "Use a PIN to seal the encryption key to the TPM (default is sealing to the SRK without password)") 24 | ) 25 | 26 | // Encryption key used at enroll time. It needs to be globally available 27 | // to be accessible from the protocol functions 28 | var enrollkey []byte 29 | 30 | const ULTRABLUE_KEYS_PATH = "/etc/ultrablue/" 31 | 32 | /* 33 | initLogger sets the level of logging 34 | according to the loglevel parameter. 35 | Here is a short description of the log 36 | levels: 37 | - 0: No log 38 | - 1: Protocol steps logs 39 | - 2: Debug messages 40 | - 3: BLE packets trace 41 | */ 42 | func initLogger(loglevel int) { 43 | switch loglevel { 44 | case 1: 45 | logrus.SetLevel(logrus.InfoLevel) 46 | case 2: 47 | logrus.SetLevel(logrus.DebugLevel) 48 | case 3: 49 | logrus.SetLevel(logrus.TraceLevel) 50 | default: 51 | logrus.SetLevel(logrus.ErrorLevel) 52 | } 53 | } 54 | 55 | /* 56 | ARCHITECTURE OVERVIEW 57 | 58 | Ultrablue is a client-server application that operates over 59 | Bluetooth Low Energy (BLE). This tool acts as the server. 60 | 61 | BLE is a client-driven transport layer, in the sense that 62 | the server exposes characteristics and it is up to the client to read or 63 | write them whenever it wants. 64 | 65 | Ultrablue implements an inversion of control through several components, 66 | abstracting the BLE layer and allowing the server to drive the exchange. 67 | Each of those components, briefly described here, is implemented in a 68 | dedicated file named after the functionality. 69 | 70 | - The characteristic: Bluetooth Low Energy sends/receives data through 71 | characteristics. When a characteristic is advertised by a device, clients 72 | are able to see it and can read/write on it if allowed by the advertising 73 | device. When a client performs a read/write operation, the characteristic 74 | will process it through handlers. 75 | Ultrablue only exposes one characteristic, enabled both for reading and 76 | writing. 77 | 78 | - The state: The state is a data structure that holds information about the 79 | currently running read/write operation on the characteristic for a 80 | connection. 81 | It has a go channel, that abstracts the characteristic handlers and makes 82 | possible to read/write full messages to the channel without dealing with BLE 83 | internals (chunking, size prefix...). 84 | The state is created on the first client interaction with the characteristic, 85 | and lives as long as the client connection does. 86 | When the state is created, it also runs a protocol instance that operates 87 | on the above-mentioned channel and runs in a dedicated goroutine. 88 | 89 | - The session: It wraps the state channel and keeps information on how to 90 | read/write data into it, e.g. if messages must be encrypted/decrypted. 91 | A StartEncryption method is available so that the caller can make the session 92 | encrypted whenever they want. 93 | The session also exposes two functions to communicate with clients: SendMsg 94 | and recvMsg. These functions make use of the abstractions provided by lower 95 | layers to operate on the characteristic (through the go channel of the 96 | state); upper layers should never need to call the lower layers directly. 97 | 98 | - The protocol: It implements the actual remote attestation routine. It is 99 | split in several steps, each implemented in its own function. Thanks to 100 | previous abstractions, the server doesn't care of the transport layer and 101 | only relies on the session and its exported methods/functions to communicate 102 | with clients. 103 | 104 | Note: The server only accepts one simulteanous client. 105 | */ 106 | 107 | func main() { 108 | flag.Parse() 109 | initLogger(*loglevel) 110 | 111 | logrus.Info("Opening the default HCI device") 112 | device, err := linux.NewDevice() 113 | if err != nil { 114 | logrus.Fatal(err) 115 | } 116 | ble.SetDefaultDevice(device) 117 | defer device.Stop() 118 | 119 | if *enroll { 120 | logrus.Info("Generating symmetric key") 121 | if enrollkey, err = TPM2_GetRandom(32); err != nil { 122 | logrus.Fatal(err) 123 | } 124 | } 125 | 126 | logrus.Info("Registering ultrablue service and characteristic") 127 | ultrablueSvc := ble.NewService(ultrablueSvcUUID) 128 | ultrablueSvc.AddCharacteristic(UltrablueChr(*mtu)) 129 | if err := ble.AddService(ultrablueSvc); err != nil { 130 | logrus.Fatal(err) 131 | } 132 | 133 | logrus.Info("Start advertising") 134 | ctx := ble.WithSigHandler(context.WithCancel(context.Background())) 135 | go ble.AdvertiseNameAndServices(ctx, "Ultrablue server", ultrablueSvc.UUID) 136 | 137 | if *enroll { 138 | logrus.Info("Generating enrollment QR code") 139 | addr := device.Address().String() 140 | json := fmt.Sprintf(`{"addr":"%s","key":"%x"}`, addr, enrollkey) 141 | qrcode, err := generateQRCode(json) 142 | if err != nil { 143 | logrus.Fatal(err) 144 | } 145 | fmt.Print(qrcode) 146 | } 147 | 148 | select { 149 | case <-ctx.Done(): 150 | logrus.Fatal(ctx.Err()) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /server/protocol.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "crypto/rsa" 9 | "errors" 10 | "os" 11 | "reflect" 12 | 13 | "github.com/google/go-attestation/attest" 14 | "github.com/google/uuid" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const PCR_EXTENSION_INDEX = 9 19 | 20 | 21 | // ------------- PROTOCOL FUNCTIONS ---------------- // 22 | 23 | /* 24 | Note on error handling: In the following functions, the channel 25 | is closed on some errors, but not on others. 26 | We must close the channel on error only if it is 27 | not already closed, that is, on errors that comes from 28 | the application layer. 29 | When the error comes from the transport layer (the 30 | sendMsg/recvMsg functions), the channel has already been 31 | closed, and the program will panic if we try to close it 32 | again. 33 | */ 34 | 35 | // EnrollData contains the TPM's endorsement RSA public key 36 | // with an optional certificate. 37 | // It is used to deconstruct complex crypto.Certificate go type 38 | // in order to encode and send it. 39 | // It also contains a boolean @PCRExtend that indicates the new verifier 40 | // it must generate a new secret to send back on attestation success. 41 | type EnrollData struct { 42 | EKCert []byte // x509 key certificate (one byte set to 0 if none) 43 | EKPub []byte // Raw public key bytes 44 | EKExp int // Public key exponent 45 | PCRExtend bool // Whether or not PCR_EXTENSION_INDEX must be extended on attestation success 46 | } 47 | 48 | // As encoding raw byte arrays to CBOR is not handled very well by 49 | // most libraries out there, we encapsulate those in a one-field 50 | // structure. 51 | type Bytestring struct { 52 | Bytes []byte 53 | } 54 | 55 | func parseAttestEK(ek *attest.EK) (EnrollData, error) { 56 | if reflect.TypeOf(ek.Public).String() != "*rsa.PublicKey" { 57 | return EnrollData{}, errors.New("Invalid key type:" + reflect.TypeOf(ek.Public).String()) 58 | } 59 | var c []byte = make([]byte, 0) 60 | if ek.Certificate != nil { 61 | c = ek.Certificate.Raw 62 | } 63 | var n = ek.Public.(*rsa.PublicKey).N.Bytes() 64 | var e = ek.Public.(*rsa.PublicKey).E 65 | return EnrollData{c, n, e, *pcrextend}, nil 66 | } 67 | 68 | func establishEncryptedSession(ch chan []byte) (*Session, error) { 69 | var data Bytestring 70 | var key []byte 71 | var session = NewSession(ch) 72 | var err error 73 | 74 | logrus.Info("Getting client UUID") 75 | if err = recvMsg(&data, session); err != nil { 76 | return nil, err 77 | } 78 | if session.uuid, err = uuid.FromBytes(data.Bytes); err != nil { 79 | close(ch) 80 | return nil, err 81 | } 82 | 83 | if *enroll { 84 | key = enrollkey 85 | enrollkey = nil 86 | logrus.Info("Saving UUID & encryption key") 87 | err = storeKey(session.uuid.String(), key) 88 | } else { 89 | logrus.Info("Fetching encryption key") 90 | key, err = loadKey(session.uuid.String()) 91 | } 92 | if err != nil { 93 | close(ch) 94 | return nil, err 95 | } 96 | if err := session.StartEncryption(key); err != nil { 97 | close(ch) 98 | return nil, err 99 | } 100 | return session, nil 101 | } 102 | 103 | func enrollment(session *Session, tpm *attest.TPM) error { 104 | logrus.Info("Retrieving EK pub and EK cert") 105 | eks, err := tpm.EKs() 106 | if err != nil { 107 | close(session.ch) 108 | return err 109 | } 110 | logrus.Info("Sending enrollment data") 111 | 112 | // Any key should do the job in principle, use the first one 113 | // as we expect it to include a certificate. 114 | ek, err := parseAttestEK(&eks[0]) 115 | if err != nil { 116 | close(session.ch) 117 | return nil 118 | } 119 | err = sendMsg(ek, session) 120 | if err != nil { 121 | return err 122 | } 123 | return nil 124 | } 125 | 126 | func authentication(session *Session) error { 127 | logrus.Info("Starting authentication process") 128 | logrus.Info("Generating nonce") 129 | rbytes, err := TPM2_GetRandom(16) 130 | if err != nil { 131 | close(session.ch) 132 | return err 133 | } 134 | nonce := Bytestring{rbytes} 135 | logrus.Info("Sending nonce") 136 | err = sendMsg(nonce, session) 137 | if err != nil { 138 | return err 139 | } 140 | logrus.Info("Getting nonce back") 141 | var rcvd_nonce Bytestring 142 | err = recvMsg(&rcvd_nonce, session) 143 | if err != nil { 144 | return err 145 | } 146 | logrus.Info("Verifying nonce") 147 | if bytes.Equal(nonce.Bytes, rcvd_nonce.Bytes) == false { 148 | close(session.ch) 149 | return errors.New("Authentication failure: nonces differ") 150 | } 151 | logrus.Info("The client is now authenticated") 152 | return nil 153 | } 154 | 155 | func credentialActivation(session *Session, tpm *attest.TPM) (*attest.AK, error) { 156 | logrus.Info("Generating AK") 157 | ak, err := tpm.NewAK(nil) 158 | if err != nil { 159 | close(session.ch) 160 | return nil, err 161 | } 162 | err = sendMsg(ak.AttestationParameters(), session) 163 | if err != nil { 164 | return nil, err 165 | } 166 | logrus.Info("Getting credential blob") 167 | var ec attest.EncryptedCredential 168 | err = recvMsg(&ec, session) 169 | if err != nil { 170 | return nil, err 171 | } 172 | logrus.Info("Decrypting credential blob") 173 | decrypted, err := ak.ActivateCredential(tpm, ec) 174 | if err != nil { 175 | close(session.ch) 176 | return nil, err 177 | } 178 | logrus.Info("Sending back decrypted credential blob") 179 | err = sendMsg(Bytestring{decrypted}, session) 180 | if err != nil { 181 | return nil, err 182 | } 183 | return ak, nil 184 | } 185 | 186 | func attestation(session *Session, tpm *attest.TPM, ak *attest.AK) error { 187 | logrus.Info("Getting anti replay nonce") 188 | var nonce Bytestring 189 | err := recvMsg(&nonce, session) 190 | if err != nil { 191 | return err 192 | } 193 | logrus.Info("Retrieving attestation plateform data") 194 | ap, err := tpm.AttestPlatform(ak, nonce.Bytes, nil) 195 | if err != nil { 196 | close(session.ch) 197 | return err 198 | } 199 | err = sendMsg(ap, session) 200 | if err != nil { 201 | return err 202 | } 203 | return nil 204 | } 205 | 206 | func response(session *Session) error { 207 | logrus.Info("Getting attestation response") 208 | var response struct { 209 | Err bool 210 | Secret []byte 211 | } 212 | err := recvMsg(&response, session) 213 | if err != nil { 214 | return err 215 | } 216 | if response.Err { 217 | close(session.ch) 218 | return errors.New("Attestation failure") 219 | } 220 | if *enroll { 221 | logrus.Info("Enrollment success") 222 | } else { 223 | logrus.Info("Attestation success") 224 | } 225 | if len(response.Secret) > 0 { 226 | logrus.Info("Extending PCR", PCR_EXTENSION_INDEX) 227 | if err = TPM2_PCRExtend(PCR_EXTENSION_INDEX, response.Secret); err != nil { 228 | return err 229 | } 230 | } 231 | return nil 232 | } 233 | 234 | /* 235 | ultrablueProtocol is the function that drives 236 | the server-client interaction, and implements the 237 | attestation protocol. It runs in a go routine, and 238 | closely cooperates with the ultrablueChr go-routine 239 | through the @ch channel. (As pointed out at the 240 | top of main.go, the BLE client has the control over 241 | the communication.) 242 | 243 | About error handling: When the error comes from the 244 | sendMsg/recvMsg methods, we can just return, and assume the 245 | connection has already been closed. When the 246 | error comes from the protocol, we need to first 247 | close the channel, to notify the characteristic that 248 | it needs to close the connection on the next client interaction. 249 | */ 250 | func ultrablueProtocol(ch chan []byte) { 251 | var session *Session 252 | 253 | tpm, err := attest.OpenTPM(nil) 254 | if err != nil { 255 | logrus.Fatal(err) 256 | } 257 | defer tpm.Close() 258 | 259 | if session, err = establishEncryptedSession(ch); err != nil { 260 | logrus.Error(err); return 261 | } 262 | err = authentication(session) 263 | if err != nil { 264 | logrus.Error(err) 265 | return 266 | } 267 | if *enroll { 268 | err = enrollment(session, tpm) 269 | if err != nil { 270 | logrus.Error(err) 271 | return 272 | } 273 | } 274 | ak, err := credentialActivation(session, tpm) 275 | if err != nil { 276 | logrus.Error(err) 277 | return 278 | } 279 | err = attestation(session, tpm, ak) 280 | if err != nil { 281 | logrus.Error(err) 282 | return 283 | } 284 | err = response(session) 285 | if err != nil { 286 | logrus.Error(err) 287 | os.Exit(1) 288 | } 289 | os.Exit(0) 290 | } 291 | -------------------------------------------------------------------------------- /server/protocol_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/fxamacker/cbor/v2" 11 | ) 12 | 13 | func TestSendMsg_NormalCase(t *testing.T) { 14 | var data = []int{4, 8, 15, 16, 23, 42} 15 | var session = &Session { 16 | ch: make(chan []byte), 17 | } 18 | 19 | // Emulate a successful client read on the characteristic 20 | go func(ch chan []byte, t *testing.T) { 21 | _, ok := <-ch 22 | if !ok { 23 | t.Fatal("The channel has been closed") 24 | } 25 | ch <- nil 26 | }(session.ch, t) 27 | 28 | // Check that no error occured 29 | err := sendMsg(data, session) 30 | if err != nil { 31 | t.Errorf("Failed to send data %v", data) 32 | } 33 | } 34 | 35 | func TestSendMsg_WithError(t *testing.T) { 36 | var data = []int{4, 8, 15, 16, 23, 42} 37 | var session = &Session { 38 | ch: make(chan []byte), 39 | } 40 | 41 | // Emulate a client read on the characteristic that 42 | // fails , in a goroutine. 43 | go func(ch chan []byte, t *testing.T) { 44 | _, ok := <-ch 45 | if !ok { 46 | t.Fatal("The channel has been closed") 47 | } 48 | close(ch) // This means an error occured 49 | }(session.ch, t) 50 | 51 | // Make sure an error occured 52 | err := sendMsg(data, session) 53 | if err == nil { 54 | t.Error("sendMsg succeeded whereas the channel closed unexpectedly") 55 | } 56 | } 57 | 58 | /* 59 | // I'm sad to admit it, but I can't manage to make the cbor.Marshal function fail... 60 | func TestSendMsg_EncodingFailure(t *testing.T) { 61 | var data = []int {4, 8, 15, 16, 23, 42} 62 | var ch = make(chan []byte) 63 | 64 | // Emulate a client read on the characteristic 65 | go func(ch chan []byte, t *testing.T) { 66 | _, ok := <- ch 67 | if !ok { 68 | t.Fatal("The channel has been closed") 69 | } 70 | ch <- nil 71 | }(ch , t) 72 | 73 | // Make sure an error occured 74 | err := sendMsg(data, ch) 75 | if err == nil { 76 | t.Error("sendMsg succeeded whereas the channel closed unexpectedly") 77 | } 78 | } 79 | */ 80 | 81 | func TestRecvMsg_NormalCase(t *testing.T) { 82 | var data []int 83 | var expected = []int{4, 8, 15, 16, 23, 42} 84 | var session = &Session { 85 | ch: make(chan []byte), 86 | } 87 | 88 | // Emulate a successful client write on the characteristic 89 | go func(ch chan []byte, t *testing.T) { 90 | var data = []int{4, 8, 15, 16, 23, 42} 91 | var encoded, err = cbor.Marshal(data) 92 | if err != nil { 93 | t.Fatalf("Failed to encode %#v as CBOR", data) 94 | } 95 | ch <- encoded 96 | }(session.ch, t) 97 | 98 | // Check that no error occured 99 | err := recvMsg(&data, session) 100 | if err != nil { 101 | t.Error("Failed to receive data") 102 | } 103 | 104 | // Check the received data 105 | if !reflect.DeepEqual(data, expected) { 106 | t.Errorf("Expected: %#v, Received: %#v", expected, data) 107 | } 108 | } 109 | 110 | func TestRecvMsg_ChannelError(t *testing.T) { 111 | var data []int 112 | var session = &Session { 113 | ch: make(chan []byte), 114 | } 115 | 116 | // Emulate a channel error while a client is writing on 117 | // the characteristic. 118 | go func(ch chan []byte, t *testing.T) { 119 | close(ch) 120 | }(session.ch, t) 121 | 122 | // Make sure the error is caught 123 | err := recvMsg(&data, session) 124 | if err == nil { 125 | t.Error("recvMsg succeeded whereas the channel closed unecpectedly.") 126 | } 127 | } 128 | 129 | func TestRecvMsg_InvalidCBOR(t *testing.T) { 130 | var data []int 131 | var session = &Session { 132 | ch: make(chan []byte), 133 | } 134 | 135 | // Emulate a successful client write on the characteristic 136 | go func(ch chan []byte, t *testing.T) { 137 | var encoded = []byte{0x38, 0x18, 0x12} // Invalid CBOR 138 | ch <- encoded 139 | }(session.ch, t) 140 | 141 | // Make sure an error occured 142 | err := recvMsg(&data, session) 143 | if err == nil { 144 | t.Error("recvMsg succeeded whereas invalid CBOR data was sent") 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /server/session.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "crypto/aes" 8 | "crypto/cipher" 9 | "errors" 10 | 11 | "github.com/fxamacker/cbor/v2" 12 | "github.com/google/uuid" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | /* 17 | The Session type is an abstraction on top of the Go channel used to pass 18 | binary data to the goroutine reading and writing on the BLE characteristic. 19 | It takes care of automatically encoding and encrypting Go objects before 20 | sending them on the channel, and the other way around for received messages. 21 | */ 22 | type Session struct { 23 | ch chan []byte 24 | aesgcm cipher.AEAD 25 | encrypted bool 26 | uuid uuid.UUID 27 | } 28 | 29 | /* 30 | Creates and returns a new Session for the given channel 31 | */ 32 | func NewSession(ch chan []byte) *Session { 33 | return &Session { 34 | ch: ch, 35 | } 36 | } 37 | 38 | /* 39 | Creates an AES/GCM cipher from the given key and stores it 40 | in the Session. Also marks it as encrypted, so that 41 | subsequent calls to sendMsg/recvMsg with this Session will 42 | be encrypted. 43 | */ 44 | func (s *Session) StartEncryption(key []byte) error { 45 | if s.encrypted { 46 | return errors.New("The session is already encrypted") 47 | } 48 | 49 | block, err := aes.NewCipher(key) 50 | if err != nil { 51 | return err 52 | } 53 | if s.aesgcm, err = cipher.NewGCM(block); err != nil { 54 | return err 55 | } 56 | s.encrypted = true 57 | return nil 58 | } 59 | 60 | /* 61 | sendMsg takes the data to send, which is a generic, 62 | and sends it to the message channel (through the session) 63 | of the connection state, encoded to CBOR. 64 | This will make the message available to read 65 | on the characteristic, and the function will 66 | block until the client reads it completely. 67 | 68 | If an error arises, and the channel is still open, 69 | sendMsg closes it. 70 | */ 71 | func sendMsg[T any](obj T, session *Session) error { 72 | logrus.Debug("Encoding to CBOR") 73 | data, err := cbor.Marshal(obj) 74 | if err != nil { 75 | close(session.ch) 76 | return err 77 | } 78 | if session.encrypted { 79 | logrus.Debug("Encrypting (AES/GCM)") 80 | iv, err := TPM2_GetRandom(uint16(session.aesgcm.NonceSize())) 81 | if err != nil { 82 | close(session.ch) 83 | return err 84 | } 85 | data = session.aesgcm.Seal(iv, iv, data, nil) // Append encrypted data to the IV 86 | } 87 | logrus.Debug("Sending message") 88 | session.ch <- data 89 | _, ok := <-session.ch 90 | if !ok { 91 | return errors.New("The channel has been closed") 92 | } 93 | return nil 94 | } 95 | 96 | /* 97 | recvMsg blocks until a message has been fully 98 | written by the client on the characteristic. 99 | It then tries to decode the CBOR message, and 100 | stores it in the obj parameter. Since obj is declared 101 | beforehand, and has a strong type, the cbor package 102 | will be able to decode it. 103 | 104 | If an error arises, and the channel is still open, 105 | recvMsg closes it. 106 | */ 107 | func recvMsg[T any](obj *T, session *Session) error { 108 | var err error 109 | 110 | logrus.Debug("Receiving message") 111 | data, ok := <-session.ch 112 | if !ok { 113 | return errors.New("The channel has been closed") 114 | } 115 | if session.encrypted { 116 | logrus.Debug("Decrypting (AES/GCM)") 117 | nonceSize := session.aesgcm.NonceSize() 118 | if len(data) < nonceSize { 119 | return errors.New("Data not large enough to be prefixed with the IV. Must be at least 12 bytes") 120 | } 121 | if data, err = session.aesgcm.Open(nil, data[:nonceSize], data[nonceSize:], nil); err != nil { 122 | close(session.ch) 123 | return err 124 | } 125 | } 126 | logrus.Debug("Decoding from CBOR") 127 | if err = cbor.Unmarshal(data, obj); err != nil { 128 | close(session.ch) 129 | return err 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /server/state.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/go-ble/ble" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type OpKind int 15 | 16 | const ( 17 | Idle = iota 18 | Read 19 | Write 20 | ) 21 | 22 | /* 23 | State struct is used to keep informations about the current 24 | attestation state, between the attester (server) and a 25 | verifier (client). 26 | */ 27 | type State struct { 28 | 29 | /* 30 | Buf is the buffer in which the messages that are exchanged 31 | between the client and the server are stored while they 32 | are processed. 33 | As the messages are chunked in several packets, we need 34 | to keep track of additional data, Offset and Msglen. 35 | */ 36 | Buf []byte 37 | 38 | /* 39 | Indicates wether we're reading, writing or idling. 40 | */ 41 | operation OpKind 42 | 43 | /* 44 | Offset is the number of bytes from a message that the server 45 | already sent to the client, if we are in a write operation, 46 | -1 otherwise. 47 | */ 48 | Offset int 49 | 50 | /* 51 | Msglen is the size of the message the server expects to 52 | receive from the client if we're in a read operation, 53 | -1 otherwise. 54 | */ 55 | Msglen int 56 | 57 | /* 58 | messageCh is a channel used to transmit messages between the 59 | linear ultrablueProtocol function and ultrablueChr callbacks. 60 | It is a bidirectional channel. 61 | In case of failure during the BLE message exchange, the characteristic 62 | will close this channel, close the connection, and the ultrablueProtocol 63 | function will return. 64 | */ 65 | ch chan []byte 66 | } 67 | 68 | // Key type to get/set the state value for 69 | // a context. 70 | type key int 71 | 72 | /* 73 | getConnectionState returns the attestation state 74 | for the given connection. If there's no 75 | value, it sets it with a newly created state, and 76 | returns it. 77 | 78 | This is useful to keep a separate state for each 79 | connection, and avoid leaks. 80 | */ 81 | func getConnectionState(conn ble.Conn) *State { 82 | var connCtx = conn.Context() 83 | var stateKey key 84 | 85 | if connCtx.Value(stateKey) == nil { 86 | s := &State{ 87 | ch: make(chan []byte), 88 | } 89 | s.reset() 90 | ctx := context.WithValue(connCtx, stateKey, s) 91 | conn.SetContext(ctx) 92 | connCtx = ctx 93 | // Start the attestation protocol that runs in a goroutine 94 | // and reads/receives messages through the channel. 95 | go ultrablueProtocol(s.ch) 96 | } 97 | return connCtx.Value(stateKey).(*State) 98 | } 99 | 100 | func (s *State) isComplete() bool { 101 | if s.operation == Read && len(s.Buf) == s.Msglen { 102 | return true 103 | } 104 | if s.operation == Write && len(s.Buf) == s.Offset { 105 | return true 106 | } 107 | return false 108 | } 109 | 110 | func (s *State) reset() { 111 | s.Buf = nil 112 | s.Msglen = -1 113 | s.Offset = -1 114 | s.operation = Idle 115 | } 116 | 117 | /* 118 | check is an internal State method that 119 | asserts the state is valid. 120 | Exits in case of error. 121 | */ 122 | func (s *State) check() { 123 | var isValid bool 124 | 125 | switch s.operation { 126 | case Idle: 127 | isValid = len(s.Buf) == 0 && s.Offset == -1 && s.Msglen == -1 128 | case Read: 129 | isValid = s.Msglen >= 0 && s.Offset == -1 130 | case Write: 131 | isValid = s.Offset >= 0 && s.Msglen == -1 132 | } 133 | 134 | if isValid == false { 135 | logrus.Fatalf("Invalid state: operation=%d, Buf length=%d, Offset=%d, Msglen=%d", s.operation, len(s.Buf), s.Offset, s.Msglen) 136 | } 137 | } 138 | 139 | /* 140 | StartOperation puts the connection in a "busy" state, meaning 141 | a message is being sent or received. If another call to StartOperation 142 | is made before EndOperation, it will raise an error. 143 | In other words, StartOperation and EndOperation calls must match one to one. 144 | */ 145 | func (s *State) StartOperation(kind OpKind) error { 146 | s.check() 147 | if kind == Idle { 148 | return errors.New("Cannot start an Idle operation.") 149 | } 150 | if s.operation != Idle { 151 | return errors.New("An operation is already in progress") 152 | } 153 | s.operation = kind 154 | switch kind { 155 | case Read: 156 | s.Msglen = 0 157 | case Write: 158 | s.Offset = 0 159 | } 160 | s.check() 161 | return nil 162 | } 163 | 164 | /* 165 | EndOperation puts the connection back in the Idle state. This indicates 166 | that the client is now able to send/receive a message. Internally, it 167 | clears the message buffer and helper variables. 168 | The function is closely related to StartOperation func, see above. 169 | It also checks that the contract of the message has been fullfilled, 170 | which implies that either: 171 | - The number of sent bytes matches the message size. 172 | - The number of received bytes matches the message size prefix. 173 | if those condition aren't satisfied, an error is raised. 174 | */ 175 | func (s *State) EndOperation() error { 176 | s.check() 177 | if s.operation == Idle { 178 | return errors.New("There is no operation in progress") 179 | } 180 | 181 | if !s.isComplete() { 182 | switch s.operation { 183 | case Read: 184 | return errors.New("the read operation hasn't been completed") 185 | case Write: 186 | return errors.New("the write operation hasn't been completed") 187 | } 188 | } 189 | s.reset() 190 | s.check() 191 | return nil 192 | } 193 | -------------------------------------------------------------------------------- /server/state_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | 10 | "github.com/go-ble/ble" 11 | ) 12 | 13 | /* 14 | All the following pain, only to implement the Conn interface 15 | of the go-ble package, which doesn't provide a test type. 16 | */ 17 | 18 | type FakeConn struct { 19 | context context.Context 20 | } 21 | 22 | func (fc *FakeConn) Context() context.Context { 23 | return fc.context 24 | } 25 | 26 | func (fc *FakeConn) SetContext(ctx context.Context) { 27 | fc.context = ctx 28 | } 29 | 30 | func (fc *FakeConn) LocalAddr() ble.Addr { 31 | return ble.NewAddr("42:42:42:42:42:42") 32 | } 33 | 34 | func (fc *FakeConn) RemoteAddr() ble.Addr { 35 | return ble.NewAddr("42:42:42:42:42:42") 36 | } 37 | 38 | func (fc *FakeConn) RxMTU() int { 39 | return 0 40 | } 41 | 42 | func (fc *FakeConn) SetRxMTU(mtu int) { 43 | 44 | } 45 | 46 | func (fc *FakeConn) TxMTU() int { 47 | return 0 48 | } 49 | 50 | func (fc *FakeConn) SetTxMTU(mtu int) { 51 | 52 | } 53 | 54 | func (fc *FakeConn) ReadRSSI() int { 55 | return 0 56 | } 57 | 58 | func (fc *FakeConn) Close() error { 59 | return nil 60 | } 61 | 62 | func (fc *FakeConn) Write(p []byte) (int, error) { 63 | return len(p), nil 64 | } 65 | 66 | func (fc *FakeConn) Read(p []byte) (int, error) { 67 | return len(p), nil 68 | } 69 | 70 | func (fc *FakeConn) Disconnected() <-chan struct{} { 71 | return make(chan struct{}) 72 | } 73 | 74 | /* 75 | Tests that the getConnectionState function well 76 | sets the stateKey value for the connection 77 | context on the first call 78 | */ 79 | func TestGetConnectionState(t *testing.T) { 80 | var conn = FakeConn{ 81 | context: context.Background(), 82 | } 83 | 84 | var stateKey key 85 | if conn.Context().Value(stateKey) != nil { 86 | t.Errorf("Context has value for stateKey key: %+v", conn.Context()) 87 | } 88 | _ = getConnectionState(&conn) 89 | if conn.Context().Value(stateKey) == nil { 90 | t.Errorf("Context value for stateKey key is nil: %+v", conn.Context()) 91 | } 92 | } 93 | 94 | func TestStartOperation_NormalCase(t *testing.T) { 95 | var s = State{} 96 | s.reset() 97 | 98 | err := s.StartOperation(Read) 99 | if err != nil { 100 | t.Error("StartOperation failed, whereas no operation is in progress") 101 | } 102 | } 103 | 104 | func TestStartOperation_AnotherOperationInProgress(t *testing.T) { 105 | var s = State{ 106 | operation: Read, 107 | Msglen: 0, 108 | Offset: -1, 109 | } 110 | 111 | err := s.StartOperation(Write) 112 | if err == nil { 113 | t.Error("StartOperation succeeded whereas an operation is running") 114 | } 115 | } 116 | 117 | func TestEndOperation_WholeMessageWritten(t *testing.T) { 118 | var s = State{ 119 | operation: Write, 120 | Buf: make([]byte, 8), 121 | Offset: 8, 122 | Msglen: -1, 123 | } 124 | err := s.EndOperation() 125 | if err != nil { 126 | t.Errorf("EndOperation failed, whereas an operation completed successfully: %+v", s) 127 | } 128 | if s.Buf != nil { 129 | t.Errorf("The buffer hasn't been reset correctly: %+v", s) 130 | } 131 | if s.Offset != -1 { 132 | t.Errorf("The offset hasn't been reset correctly: %+v", s) 133 | } 134 | } 135 | 136 | func TestEndOperation_BufBiggerThanMsglen(t *testing.T) { 137 | var s = State{ 138 | operation: Read, 139 | Buf: make([]byte, 16), 140 | Msglen: 12, 141 | Offset: -1, 142 | } 143 | err := s.EndOperation() 144 | if err == nil { 145 | t.Errorf("EndOperation succeeded whereas the running operation wasn't valid: %+v", s) 146 | } 147 | } 148 | 149 | func TestEndOperation_ReadingNotComplete(t *testing.T) { 150 | var s = State{ 151 | operation: Read, 152 | Buf: make([]byte, 16), 153 | Msglen: 20, 154 | Offset: -1, 155 | } 156 | err := s.EndOperation() 157 | if err == nil { 158 | t.Errorf("EndOperation succeeded whereas the running operation wasn't complete: %+v", s) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /server/testbed/Makefile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | # 4 | hostbus := $(shell lsusb | grep Bluetooth | sed -r 's/.*Bus ([0-9]+){1}.*/\1/') 5 | hostaddr := $(shell lsusb | grep Bluetooth | sed -r 's/.*Device ([0-9]+){1}.*/\1/') 6 | 7 | # Allow building with `make DISTRIBUTION=debian` 8 | # `man mkosi` for a list of distributions 9 | DISTRIBUTION= 10 | 11 | MKOSI_FLAGS= 12 | ifeq ($(DISTRIBUTION),) 13 | else 14 | MKOSI_FLAGS += --distribution=$(DISTRIBUTION) 15 | endif 16 | 17 | # A few useful aliases - by default, build and run. 18 | quick: build run 19 | full: scratch-build run 20 | debug: debug-build run 21 | build: cache-build 22 | 23 | scratch-build: install 24 | sudo mkosi $(MKOSI_FLAGS) -ff 25 | 26 | cache-build: install 27 | sudo mkosi $(MKOSI_FLAGS) --incremental -f 28 | 29 | debug-build: install 30 | sudo mkosi $(MKOSI_FLAGS) --kernel-command-line="systemd.log_level=debug systemd.log_target=console" --force 31 | 32 | install: 33 | mkdir -p mkosi.extra/usr/lib/dracut/modules.d 34 | cp -r ../dracut/90ultrablue mkosi.extra/usr/lib/dracut/modules.d/ 35 | mkdir -p mkosi.extra/etc/dracut.conf.d 36 | cp ../dracut/90crypttab.conf mkosi.extra/etc/dracut.conf.d 37 | mkdir -p mkosi.extra/usr/lib/systemd/system/ 38 | cp ../unit/ultrablue-server.service mkosi.extra/usr/lib/systemd/system/ 39 | mkdir -p mkosi.extra/etc 40 | cp ressources/crypttab mkosi.extra/etc/crypttab 41 | 42 | run: 43 | mkdir -p /tmp/emulated_tpm/ultrablue 44 | 45 | swtpm socket \ 46 | --tpmstate dir=/tmp/emulated_tpm/ultrablue \ 47 | --ctrl type=unixio,path=/tmp/emulated_tpm/ultrablue/swtpm-sock \ 48 | --log level=20 --tpm2 --daemon 49 | 50 | sudo mkosi $(MKOSI_FLAGS) qemu \ 51 | -chardev socket,id=chrtpm,path=/tmp/emulated_tpm/ultrablue/swtpm-sock \ 52 | -tpmdev emulator,id=tpm0,chardev=chrtpm \ 53 | -device tpm-tis,tpmdev=tpm0 \ 54 | -usb -device usb-host,hostbus=${hostbus},hostaddr=${hostaddr} 55 | 56 | -------------------------------------------------------------------------------- /server/testbed/README.md: -------------------------------------------------------------------------------- 1 | # Virtual testbed setup 2 | 3 | This testbed allows you to generate a linux distribution image (eg. archlinux, 4 | fedora, debian) with an embedded and configured ultrablue server. 5 | 6 | ## Quickstart 7 | 8 | ```bash 9 | make build 10 | # The passphrase to unlock the disk is in specified in mkosi.passphrase. 11 | make run 12 | ``` 13 | 14 | The VM should start and log you in as root. From there: 15 | 16 | ```bash 17 | # Check that PCR 9 is all-zero 18 | tpm2_pcrread 19 | # Run this command (only once!) and add a new device from Ultrablue phone application 20 | ultrablue-server -enroll -pcr-extend 21 | # Check that PCR 9 is *not* all-zero 22 | tpm2_pcrread 23 | # Lock the disk based on the PCR value 24 | systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=9 /dev/vda2 25 | # Rebuild the initramfs to enable Ultrablue on boot 26 | dracut --add "crypt ultrablue" --force $(find /efi -name initrd) 27 | # Reboot and click "run" on your machine in the mobile app when Ultrablue starts 28 | reboot 29 | ``` 30 | 31 | If everythings works as expected, you should not need to input the passphrase upon reboot. 32 | 33 | ## Building the image 34 | 35 | ```bash 36 | make build 37 | ``` 38 | 39 | Your machine needs `swtpm` installed and a **bluetooth** device. The virtual 40 | testbed generation is made by the `mkosi` tool, that needs to be installed on 41 | your machine. 42 | 43 | The distribution defaults to the host one; you can build a different one with 44 | eg. `make DISTRIBUTION=debian`. Beware that `mkosi` needs root privileges in 45 | order to work and will write the `mkosi` and `mkosi.output` cache directories 46 | as root owned. 47 | 48 | The build script will automatically build and embbed ultrablue-server, but you 49 | need to build the client app of your choice and install it on your phone 50 | separately. 51 | 52 | ## Remote attestation 53 | 54 | Once your distro image is generated, you can boot it using the `make run` command, then unlock the disk using the 55 | passphrase written in the `mkosi.passphrase` file. 56 | 57 | You can now enroll your `ultrablue client`: 58 | ```bash 59 | ultrablue-server -enroll 60 | ``` 61 | 62 | Then, you can test remote attestation: 63 | ```bash 64 | ultrablue-server 65 | ``` 66 | and press the "run" button on your mobile client to start the attestation. 67 | 68 | 69 | ## Disk decryption based on remote attestation 70 | 71 | If you want to bind your disk encryption to **TPM2 sealing** and **ultrablue's 72 | remote attestation**, you'll then have to use the `--pcr-extend` option during 73 | enrollment. 74 | 75 | Beware that if you enrolled without `--pcr-extend`, as in the previous section, 76 | you'll have to enroll again. The cleanest way to do so is to remove the machine 77 | record from your Ultrablue mobile application, reboot the VM, and enroll once 78 | more: 79 | ```bash 80 | ultrablue-server -enroll -pcr-extend 81 | ``` 82 | 83 | Bind your disk encryption to the **TPM2 PCR9**: 84 | ```bash 85 | systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=9 /dev/vda2 86 | ``` 87 | 88 | Re-generate your initramfs in order to include the ultrablue and crypt dracut modules: 89 | ```bash 90 | # Dracut has issues finding the right initrd on Debian, provide a hint. 91 | dracut --add "crypt ultrablue" --force $(find /efi -name initrd) 92 | ``` 93 | 94 | You can now reboot the testbed and use your `ultrablue client` once asked in 95 | order to attest your machine boot. 96 | 97 | -------------------------------------------------------------------------------- /server/testbed/mkosi.build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # SPDX-FileCopyrightText: 2022 ANSSI 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | 7 | set -e 8 | 9 | [ -z "${BUILDDIR}" ] && BUILDDIR=build 10 | 11 | mkdir -p "${DESTDIR}/usr/bin" 12 | go build -o "${DESTDIR}/usr/bin/ultrablue-server" 13 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/10-ultrablue.conf: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Output] 5 | Format=gpt_btrfs 6 | Bootable=yes 7 | OutputDirectory=./mkosi.output 8 | Output=ultrablue.raw 9 | Encrypt=all 10 | WithUnifiedKernelImages=false 11 | SourceFileTransferFinal=copy-all 12 | 13 | [Host] 14 | QemuHeadless=1 15 | 16 | [Partitions] 17 | RootSize=3G 18 | 19 | [Content] 20 | Password= 21 | Autologin=yes 22 | WithNetwork=yes 23 | 24 | # Share caches with the top-level mkosi 25 | BuildDirectory=./mkosi/mkosi.builddir 26 | Cache=./mkosi/mkosi.cache 27 | 28 | BuildSources=../ 29 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/arch/10-mkosi.arch: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Distribution] 5 | Distribution=arch 6 | 7 | [Packages] 8 | BuildPackages= 9 | bluez 10 | bluez-utils 11 | go 12 | tpm2-tools 13 | tpm2-tss 14 | 15 | Packages= 16 | bluez 17 | bluez-utils 18 | cryptsetup 19 | gdb 20 | qrencode 21 | strace 22 | tpm2-tools 23 | tpm2-tss 24 | vim 25 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/debian/10-mkosi.debian: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Distribution] 5 | Distribution=debian 6 | Release=testing 7 | 8 | [Packages] 9 | BuildPackages= 10 | bluez 11 | bluez-tools 12 | golang 13 | libtss2-dev 14 | tpm2-tools 15 | 16 | Packages= 17 | bluez 18 | bluez-tools 19 | cryptsetup 20 | gdb 21 | libtss2-dev 22 | qrencode 23 | strace 24 | systemd-resolved 25 | tpm2-tools 26 | vim 27 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/fedora/10-mkosi.fedora: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Distribution] 5 | Distribution=fedora 6 | Release=36 7 | 8 | [Packages] 9 | BuildPackages= 10 | bluez 11 | bluez-tools 12 | git 13 | golang 14 | tpm2-tools 15 | tpm2-tss 16 | 17 | Packages= 18 | bluez 19 | bluez-tools 20 | cryptsetup 21 | gdb 22 | qrencode 23 | strace 24 | tpm2-tools 25 | tpm2-tss 26 | vim 27 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/opensuse/10-mkosi.opensuse: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Distribution] 5 | Distribution=opensuse 6 | Release=tumbleweed 7 | 8 | [Packages] 9 | BuildPackages= 10 | bluez 11 | go 12 | tpm2.0-tools 13 | tpm2-0-tss 14 | 15 | Packages= 16 | bluez 17 | cryptsetup 18 | gdb 19 | qrencode 20 | strace 21 | tpm2.0-tools 22 | tpm2-0-tss 23 | vim 24 | -------------------------------------------------------------------------------- /server/testbed/mkosi.default.d/ubuntu/10-mkosi.ubuntu: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 ANSSI 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [Distribution] 5 | Distribution=ubuntu 6 | Release=focal 7 | Repositories=main,universe 8 | 9 | [Packages] 10 | BuildPackages= 11 | bluez 12 | bluez-tools 13 | golang 14 | libtss2-dev 15 | tpm2-tools 16 | 17 | Packages= 18 | bluez 19 | bluez-tools 20 | cryptsetup 21 | gdb 22 | qrencode 23 | strace 24 | tpm2-tools 25 | tpm2-tss 26 | vim 27 | -------------------------------------------------------------------------------- /server/testbed/mkosi.passphrase: -------------------------------------------------------------------------------- 1 | passphrase -------------------------------------------------------------------------------- /server/testbed/mkosi.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # SPDX-FileCopyrightText: 2022 ANSSI 4 | # SPDX-License-Identifier: Apache-2.0 5 | 6 | systemctl enable bluetooth.service 7 | 8 | # Work around https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1016051 9 | # Fedora has a similar issue 10 | # On systems that have the file in /usr already, ln will just fail which is fine. 11 | ln -s /etc/dbus-1/system.d/bluetooth.conf /usr/share/dbus-1/system.d/bluetooth.conf 12 | -------------------------------------------------------------------------------- /server/testbed/ressources/crypttab: -------------------------------------------------------------------------------- 1 | root /dev/vda2 - tpm2-device=auto,tpm2-pcrs=9 2 | -------------------------------------------------------------------------------- /server/tpm2.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "io" 8 | 9 | "github.com/google/go-tpm/tpm2" 10 | "github.com/google/go-tpm/tpmutil" 11 | "github.com/pkg/errors" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // TCG TPM v2.0 Provisioning Guidance, section 7.8: 16 | // https://trustedcomputinggroup.org/wp-content/uploads/TCG-TPM-v2.0-Provisioning-Guidance-Published-v1r1.pdf 17 | const SRK_HANDLE tpmutil.Handle = 0x81000001 18 | 19 | // TCG TPM v2.0 Provisioning Guidance, section 7.5.1: 20 | // https://trustedcomputinggroup.org/wp-content/uploads/TCG-TPM-v2.0-Provisioning-Guidance-Published-v1r1.pdf 21 | // TCG EK Credential Profile, section 2.1.5.1: 22 | // https://trustedcomputinggroup.org/wp-content/uploads/Credential_Profile_EK_V2.0_R14_published.pdf 23 | var SRK_TEMPLATE = tpm2.Public { 24 | Type: tpm2.AlgRSA, 25 | NameAlg: tpm2.AlgSHA256, 26 | Attributes: tpm2.FlagFixedTPM | tpm2.FlagFixedParent | tpm2.FlagSensitiveDataOrigin | tpm2.FlagUserWithAuth | tpm2.FlagRestricted | tpm2.FlagDecrypt | tpm2.FlagNoDA, 27 | AuthPolicy: nil, 28 | RSAParameters: &tpm2.RSAParams { 29 | Symmetric: &tpm2.SymScheme { 30 | Alg: tpm2.AlgAES, 31 | KeyBits: 128, 32 | Mode: tpm2.AlgCFB, 33 | }, 34 | KeyBits: 2048, 35 | ModulusRaw: make([]byte, 256), 36 | }, 37 | } 38 | 39 | /* 40 | Returns a handle to the TPM Storage Rook Key (SRK) if it's already present 41 | in its non volatile memory. 42 | Otherwise, a new SRK is created using the default template, and is persisted 43 | in the TPM Non Volatile memory, at its default location. A handle to the newly 44 | created key is then returned 45 | */ 46 | func TPM2_LoadSRK(rwc io.ReadWriteCloser) (tpmutil.Handle, error) { 47 | // TPM2 Library commands, section 30.2: 48 | // https://trustedcomputinggroup.org/wp-content/uploads/TCG_TPM2_r1p59_Part3_Commands_pub.pdf 49 | // "TPM_CAP_HANDLES – Returns a list of all of the handles within the handle range 50 | // of the property parameter. The range of the returned handles is determined 51 | // by the handle type (the most-significant octet (MSO) of the property)." 52 | const PROPERTY = uint32(tpm2.HandleTypePersistent) << 24 53 | const MAX_OBJECTS = 256 54 | 55 | handles, _, err := tpm2.GetCapability(rwc, tpm2.CapabilityHandles, MAX_OBJECTS, PROPERTY) 56 | if err != nil { 57 | return 0, nil 58 | } 59 | for _, h := range handles { 60 | if h.(tpmutil.Handle) == SRK_HANDLE { 61 | return SRK_HANDLE, nil 62 | } 63 | } 64 | logrus.Info("SRK not found, creating one. This may take a while.") 65 | 66 | handle, _, err := tpm2.CreatePrimary(rwc, tpm2.HandleOwner, tpm2.PCRSelection{}, "", "", SRK_TEMPLATE) 67 | if err != nil { 68 | return 0, err 69 | } 70 | if err = tpm2.EvictControl(rwc, "", tpm2.HandleOwner, handle, SRK_HANDLE); err != nil { 71 | return 0, err 72 | } 73 | logrus.Infof("Persistent SRK created at NV index %x\n", SRK_HANDLE) 74 | return SRK_HANDLE, nil 75 | } 76 | 77 | /* 78 | Seals the given data to the TPM Storage Root Key (SRK) and to the 79 | provided PIN if the --with-pin command line argument is provided. 80 | Returns the resulting private and public blobs 81 | 82 | If the --with-pin command line argument is provided, a password policy 83 | will be used to seal the key. 84 | To get it back, the same policy will be needed at unseal time. 85 | */ 86 | func TPM2_Seal(data []byte, pin string) ([]byte, []byte, error) { 87 | var rwc io.ReadWriteCloser 88 | var priv, pub, policy []byte 89 | var srkHandle, sessHandle tpmutil.Handle 90 | var err error 91 | 92 | if rwc, err = tpm2.OpenTPM(); err != nil { 93 | return nil, nil, err 94 | } 95 | defer rwc.Close() 96 | 97 | if srkHandle, err = TPM2_LoadSRK(rwc); err != nil { 98 | return nil, nil, err 99 | } 100 | if sessHandle, _, err = tpm2.StartAuthSession(rwc, tpm2.HandleNull, tpm2.HandleNull, make([]byte, 16), nil, tpm2.SessionPolicy, tpm2.AlgNull, tpm2.AlgSHA256); err != nil { 101 | return nil, nil, err 102 | } 103 | 104 | // Note that we check for the command line argument rather than for an 105 | // empty PIN, because we don't want to transparently disable the password 106 | // policy for the session if the user inputs an empty string while providing 107 | // the --with-pin option. 108 | if *withpin { 109 | if err = tpm2.PolicyPassword(rwc, sessHandle); err != nil { 110 | return nil, nil, err 111 | } 112 | } 113 | 114 | if policy, err = tpm2.PolicyGetDigest(rwc, sessHandle); err != nil { 115 | return nil, nil, err 116 | } 117 | if priv, pub, err = tpm2.Seal(rwc, srkHandle, "", pin, policy, data); err != nil { 118 | return nil, nil, err 119 | } 120 | return priv, pub, err 121 | } 122 | 123 | /* 124 | Unseals the given object with the TPM Storage Root Key (SRK) 125 | and returns the original data, assuming the policy is correct: 126 | 127 | If the --with-pin command line argument is provided, a password policy is 128 | used for the session. This means that an incorrect PIN will increment 129 | the TPM DA counter, and may lock the TPM. This is wanted and prevents 130 | brute-force attacks. 131 | If the --with-pin was used at enroll time (thus sealing the key with a password 132 | policy), and it is not at attestation time: The password policy will not be 133 | used and TPM2_Unseal will return a policy error, without trying any password. 134 | In consequence, the TPM DA counter will NOT be incremented, which is also 135 | wanted because it is likely to be a usage error. 136 | */ 137 | func TPM2_Unseal(priv, pub []byte, pin string) ([]byte, error) { 138 | var rwc io.ReadWriteCloser 139 | var data []byte 140 | var srkHandle, keyHandle, sessHandle tpmutil.Handle 141 | var err error 142 | 143 | if rwc, err = tpm2.OpenTPM(); err != nil { 144 | return nil, err 145 | } 146 | defer rwc.Close() 147 | 148 | if srkHandle, err = TPM2_LoadSRK(rwc); err != nil { 149 | return nil, err 150 | } 151 | if sessHandle, _, err = tpm2.StartAuthSession(rwc, tpm2.HandleNull, tpm2.HandleNull, make([]byte, 16), nil, tpm2.SessionPolicy, tpm2.AlgNull, tpm2.AlgSHA256); err != nil { 152 | return nil, err 153 | } 154 | if keyHandle, _, err = tpm2.Load(rwc, srkHandle, "", pub, priv); err != nil { 155 | return nil, err 156 | } 157 | if *withpin { 158 | if err = tpm2.PolicyPassword(rwc, sessHandle); err != nil { 159 | return nil, err 160 | } 161 | } 162 | if data, err = tpm2.UnsealWithSession(rwc, sessHandle, keyHandle, pin); err != nil { 163 | return nil, err 164 | } 165 | return data, nil 166 | } 167 | 168 | /* 169 | Returns @size bytes of random data from the TPM 170 | */ 171 | func TPM2_GetRandom(size uint16) ([]byte, error) { 172 | rwc, err := tpm2.OpenTPM() 173 | if err != nil { 174 | return nil, err 175 | } 176 | defer rwc.Close() 177 | 178 | rbytes, err := tpm2.GetRandom(rwc, size) 179 | if err != nil { 180 | return nil, err 181 | } 182 | if rbytes == nil || len(rbytes) != int(size) || onlyContainsZeros(rbytes) { 183 | return nil, errors.New("Failed to generate random bytes") 184 | } 185 | return rbytes, nil 186 | } 187 | 188 | /* 189 | Extends the PCR at the given index with @secret 190 | TODO: The following extends the PCR, but does not adds 191 | any event log entry. This is sufficient for our needs, 192 | but it means that after an ultrablue attestation, the 193 | eventlog will differ from the pcrs, thus making any 194 | replay fail. 195 | NOTE: It seems that it's not possible to add an event log 196 | entry directly from the tss stack, and that we need to use 197 | exposed UEFI function pointers. 198 | */ 199 | func TPM2_PCRExtend(index int, secret []byte) error { 200 | rwc, err := tpm2.OpenTPM() 201 | if err != nil { 202 | return err 203 | } 204 | defer rwc.Close() 205 | 206 | return tpm2.PCREvent(rwc, tpmutil.Handle(index), secret) 207 | } 208 | -------------------------------------------------------------------------------- /server/unit/ultrablue-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ultrablue remote attestation service 3 | After=bluetooth.service 4 | Wants=bluetooth.service cryptsetup-pre.target 5 | Before=cryptsetup-pre.target 6 | DefaultDependencies=no 7 | 8 | [Service] 9 | Type=oneshot 10 | # Horrible hack to wait for bluetooth to be ready 11 | ExecStartPre=/usr/bin/sleep 5 12 | ExecStart=/usr/bin/ultrablue-server 13 | TimeoutSec=60 14 | StandardOutput=tty 15 | 16 | [Install] 17 | WantedBy=cryptsetup.target 18 | -------------------------------------------------------------------------------- /server/utils.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "syscall" 10 | 11 | "github.com/skip2/go-qrcode" 12 | "golang.org/x/term" 13 | ) 14 | 15 | /* 16 | Seals the given key with the TPM Storage Root Key 17 | and stores it under two files named after the given 18 | uuid. If those files already exists, an error is 19 | returned. 20 | */ 21 | func storeKey(uuid string, key []byte) error { 22 | var priv, pub []byte 23 | var pin []byte 24 | var err error 25 | var fpriv, fpub *os.File 26 | 27 | if *withpin { 28 | fmt.Println("Choose a PIN to seal the encryption key on disk:") 29 | if pin, err = term.ReadPassword(syscall.Stdin); err != nil { 30 | return err 31 | } 32 | } 33 | if priv, pub, err = TPM2_Seal(key, string(pin)); err != nil { 34 | return err 35 | } 36 | if err = os.MkdirAll(ULTRABLUE_KEYS_PATH, os.ModeDir); err != nil { 37 | return err 38 | } 39 | if fpriv, err = os.OpenFile(ULTRABLUE_KEYS_PATH + uuid, os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0600); err != nil { 40 | return err 41 | } 42 | defer fpriv.Close() 43 | if fpub, err = os.OpenFile(ULTRABLUE_KEYS_PATH + uuid + ".pub", os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0600); err != nil { 44 | return err 45 | } 46 | defer fpub.Close() 47 | if _, err = fpriv.Write(priv); err != nil { 48 | return err 49 | } 50 | if _, err = fpub.Write(pub); err != nil { 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | /* 57 | Gets the sealed key from the ultrablue keys directory 58 | and tries to unseal it with the TPM Storage Root Key. 59 | Returns the unsealed key on success 60 | */ 61 | func loadKey(uuid string) ([]byte, error) { 62 | var priv, pub, key []byte 63 | var pin []byte 64 | var err error 65 | 66 | if *withpin { 67 | fmt.Println("Please enter the PIN used to seal the encryption key:") 68 | if pin, err = term.ReadPassword(syscall.Stdin); err != nil { 69 | return nil, err 70 | } 71 | } 72 | if priv, err = os.ReadFile("/etc/ultrablue/" + uuid); err != nil { 73 | return nil, err 74 | } 75 | if pub, err = os.ReadFile("/etc/ultrablue/" + uuid + ".pub"); err != nil { 76 | return nil, err 77 | } 78 | if key, err = TPM2_Unseal(priv, pub, string(pin)); err != nil { 79 | return nil, err 80 | } 81 | return key, nil 82 | } 83 | 84 | /* 85 | generateQRCode generates a QR code containing the 86 | string given as parameter, and returns it in an 87 | ascii art string. 88 | */ 89 | func generateQRCode(data string) (string, error) { 90 | qr, err := qrcode.New(data, qrcode.Low) 91 | if err != nil { 92 | return "", err 93 | } 94 | return qr.ToSmallString(false), nil 95 | } 96 | 97 | /* 98 | Returns true if the data only contains zeros, false otherwise 99 | */ 100 | func onlyContainsZeros(data []byte) bool { 101 | for _, b := range data { 102 | if b != 0 { 103 | return false 104 | } 105 | } 106 | return true 107 | } 108 | -------------------------------------------------------------------------------- /server/uuids.go: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 ANSSI 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import "github.com/go-ble/ble" 7 | 8 | var ( 9 | ultrablueSvcUUID = ble.MustParse("ebee1789-50b3-4943-8396-16c0b7231cad") 10 | ultrablueChrUUID = ble.MustParse("ebee1790-50b3-4943-8396-16c0b7231cad") 11 | ) 12 | --------------------------------------------------------------------------------