What changed, and why it matters
This commit adds a brand-new firmware feature called BitBoxSync, which lets the BitBox02 hardware wallet participate in a sync service by proving its identity, signing login/admin intents, and decrypting namespace encryption keys. The code introduces new cryptographic operations (Ed25519 signatures, X25519 key exchange, HKDF, ChaCha20-Poly1305) and new user-confirmation prompts. It is a large feature addition, not a documented security fix. There is no direct evidence of a vulnerability in the diff, but any new crypto API surface carries implementation risk and should be reviewed carefully.
Treat this as a high-priority manual security review item. Audit the BitBoxSync implementation for: correct HPKE/KEM labeling and context binding, safe handling of decrypted DEKs (zeroization/lifetime), absence of malleability or replay issues in signed intents, and whether skipping confirmation for UnwrapNamespaceDek is acceptable. Verify the vendored `hkdf` crate matches the published checksum and that dependency feature flags do not pull in unwanted code. Run the new unit tests and consider additional fuzzing of the protobuf parsing and server-origin validator.
Security signals we found
New cryptographic API surface added to the hardware wallet (Ed25519, X25519, HKDF, AEAD)
Vendored third-party crate `hkdf` introduced into the firmware supply chain
New user-confirmation flow for signing sync intents; one operation (UnwrapNamespaceDek) deliberately skips confirmation
HPKE-like construction uses hard-coded suite IDs and labeled HKDF extracts/expands
Server-origin validation enforces HTTPS, ASCII, lowercase, no default port 443, and canonical port formatting
Low-order X25519 point (all-zero DH result) is rejected before shared-secret derivation
BIP-85-style root entropy derived from device seed with a new app-specific path
Evidence from the diff
The commit implements the BitBoxSync firmware API: protobuf messages, request/response dispatch, identity derivation via a BIP-85-like path, Ed25519 intent signing, and HPKE-like decryption of namespace DEKs. It vendors the hkdf crate (0.12.4) and adds dependencies on chacha20poly1305, x25519-dalek, and ed25519-dalek with zeroize. Sensitive signing operations require on-device user confirmation; UnwrapNamespaceDek intentionally does not prompt. The implementation includes length validation, low-order X25519 public-key rejection, canonical server-origin parsing, and unit tests with fixed vectors.
Changed components
src/rust/bitbox02-rust/src/hww/api/bitboxsync.rssrc/rust/bitbox02-rust/src/hww/api.rssrc/rust/bitbox02-rust/src/keystore.rssrc/rust/bitbox02-rust/src/keystore/ed25519.rsmessages/bitboxsync.protomessages/hww.protosrc/rust/bitbox02-rust/Cargo.tomlexternal/vendor/hkdf/Inspect captured patch +2883 / −22
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9d19df..c49dc17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
### [Unreleased]
- Fixed a crash when listing many backups over Bluetooth
+- Add support for BitBoxSync
### v9.26.1
- Fix a payment request validation issue
diff --git a/external/vendor/hkdf/.cargo-checksum.json b/external/vendor/hkdf/.cargo-checksum.json
new file mode 100644
index 0000000..789089c
--- /dev/null
+++ b/external/vendor/hkdf/.cargo-checksum.json
@@ -0,0 +1 @@
+{"files":{".cargo_vcs_info.json":"c279d7a6c12d37d360ad3ce31d79802af3e4ecbec270ece7681144f78b45b287","CHANGELOG.md":"a62505032740ed6600e51c157e0fddf5987aa5bd1f88afbc9acc8ff309af938c","Cargo.toml":"3cacd9169194b4749073de74b303fd26c6a861b54a29cc5a470f5f0d4378dad2","Cargo.toml.orig":"c8b55f346c15f9da113015f0c4b56c9b63a417f8f88f1276bd1c7979e7961293","LICENSE-APACHE":"59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018","LICENSE-MIT":"d288f9c9b4590446ec18c22ead8f8b5a12a3d4025b68f62dc9015063eb9cca69","README.md":"2d81cae833da6b98af93e747cb7ca024c94e4998465a56eeee3f8398be5b5071","benches/mod.rs":"ecb5e2dd2f9c65bd034edb93060d005a2e73ba4d02a6dab5088aa3dab36aa579","src/errors.rs":"5f10c52e5feab73bf3ac7dc8b5e50a149f6949747eabd5284a71bd4b0b6af552","src/lib.rs":"f0a1d6b58091b9690c168c8d772838c74f8c6937698af9fd81d2374a810bb07e","src/sealed.rs":"4d4a88eb1b4467a64f937a59e97619d8144a2b5f705cd5edf7f40cde77f6be2f","tests/data/wycheproof-sha1.blb":"b058851715d3c81bf73987dd5e3671c49a58e330735a37b4011d22c0553b5f8b","tests/data/wycheproof-sha256.blb":"294e7574c0da80a174939f474745a83b0374a232110e0f4b466ff81325280ecc","tests/data/wycheproof-sha384.blb":"fed469c38b390a3f985ba27b11575dede03b606e30484b0fa769e74101b05cd0","tests/data/wycheproof-sha512.blb":"dc4f36baff633b33fa0f71abccc91f5a1a16fa4b06569b6f970712b2972001a8","tests/tests.rs":"034946cd4f9e30249f8cd6101c7c30584be0169214a0a5a700c1489e5cdb2c92"},"package":"7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"}
\ No newline at end of file
diff --git a/external/vendor/hkdf/.cargo_vcs_info.json b/external/vendor/hkdf/.cargo_vcs_info.json
new file mode 100644
index 0000000..9d396d6
--- /dev/null
+++ b/external/vendor/hkdf/.cargo_vcs_info.json
@@ -0,0 +1,6 @@
+{
+ "git": {
+ "sha1": "1ac16e8b9d4ee7a67613c9396c6cc1327652eaba"
+ },
+ "path_in_vcs": "hkdf"
+}
\ No newline at end of file
diff --git a/external/vendor/hkdf/CHANGELOG.md b/external/vendor/hkdf/CHANGELOG.md
new file mode 100644
index 0000000..4e8c321
--- /dev/null
+++ b/external/vendor/hkdf/CHANGELOG.md
@@ -0,0 +1,115 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## 0.12.3 (2022-02-17)
+### Fixed
+- Minimal versions build ([#63])
+
+[#63]: https://github.com/RustCrypto/KDFs/pull/63
+
+## 0.12.2 (2022-01-27)
+### Fixed
+- Re-export `InvalidLength` and `InvalidPrkLength` ([#59])
+
+[#59]: https://github.com/RustCrypto/KDFs/pull/59
+
+## 0.12.1 (2022-01-27) [YANKED]
+### Added
+- Ability to switch HMAC implementation to `SimpleHmac` with respective `SimpleHkdfExtract` and `SimpleHkdf` aliases ([#57])
+
+[#57]: https://github.com/RustCrypto/KDFs/pull/55
+
+## 0.12.0 (2021-12-07)
+### Changed
+- Bump `hmac` crate dependency to v0.12 and `digest` to v0.10 ([#52])
+
+[#52]: https://github.com/RustCrypto/KDFs/pull/52
+
+## 0.11.0 (2021-04-29)
+### Added
+- Wycheproof HKDF test vectors ([#49])
+
+### Changed
+- Bump `hmac` crate dependency to v0.11 ([#50])
+
+### Fixed
+- HKDF-Extract with empty salt ([#46])
+
+[#46]: https://github.com/RustCrypto/KDFs/pull/46
+[#49]: https://github.com/RustCrypto/KDFs/pull/49
+[#50]: https://github.com/RustCrypto/KDFs/pull/50
+
+## 0.10.0 (2020-10-26)
+### Changed
+- Bump `hmac` dependency to v0.10 ([#40])
+
+[#40]: https://github.com/RustCrypto/KDFs/pull/40
+
+## 0.9.0 (2020-06-22)
+### Added
+- Multipart features for HKDF-Extract and HKDF-Expand ([#34])
+
+### Changed
+- Bump `digest` v0.9; `hmac` v0.9 ([#35])
+
+[#34]: https://github.com/RustCrypto/KDFs/pull/34
+[#35]: https://github.com/RustCrypto/KDFs/pull/35
+
+## 0.8.0 (2019-07-26)
+### Added
+- `Hkdf::from_prk()`, `Hkdf::extract()`
+
+## 0.7.1 (2019-07-15)
+
+## 0.7.0 (2018-10-16)
+### Changed
+- Update digest to 0.8
+- Refactor for API changes
+
+### Removed
+- Redundant `generic-array` crate.
+
+## 0.6.0 (2018-08-20)
+### Changed
+- The `expand` signature has changed.
+
+### Removed
+- `std` requirement
+
+## 0.5.0 (2018-05-20)
+### Fixed
+- Omitting HKDF salt.
+
+### Removed
+- Deprecated interface
+
+## 0.4.0 (2018-03-20
+### Added
+- Benchmarks
+- derive `Clone`
+
+### Changed
+- RFC-inspired interface
+- Reduce heap allocation
+- Bump deps: hex-0.3
+
+### Removed
+- Unnecessary mut
+
+## 0.3.0 (2017-11-29)
+### Changed
+- update dependencies: digest-0.7, hmac-0.5
+
+## 0.2.0 (2017-09-21)
+### Fixed
+- Support for rustc 1.20.0
+
+## 0.1.2 (2017-09-21)
+### Fixed
+- Support for rustc 1.5.0
+
+## 0.1.0 (2017-09-21)
+- Initial release
diff --git a/external/vendor/hkdf/Cargo.toml b/external/vendor/hkdf/Cargo.toml
new file mode 100644
index 0000000..ba6ed71
--- /dev/null
+++ b/external/vendor/hkdf/Cargo.toml
@@ -0,0 +1,57 @@
+# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
+#
+# When uploading crates to the registry Cargo will automatically
+# "normalize" Cargo.toml files for maximal compatibility
+# with all versions of Cargo and also rewrite `path` dependencies
+# to registry (e.g., crates.io) dependencies.
+#
+# If you are reading this file be aware that the original Cargo.toml
+# will likely look very different (and much more reasonable).
+# See Cargo.toml.orig for the original contents.
+
+[package]
+edition = "2018"
+name = "hkdf"
+version = "0.12.4"
+authors = ["RustCrypto Developers"]
+description = "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)"
+homepage = "https://github.com/RustCrypto/KDFs/"
+readme = "README.md"
+keywords = [
+ "crypto",
+ "HKDF",
+ "KDF",
+]
+categories = [
+ "cryptography",
+ "no-std",
+]
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/RustCrypto/KDFs/"
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = [
+ "--cfg",
+ "docsrs",
+]
+
+[dependencies.hmac]
+version = "0.12.1"
+
+[dev-dependencies.blobby]
+version = "0.3"
+
+[dev-dependencies.hex-literal]
+version = "0.2.2"
+
+[dev-dependencies.sha1]
+version = "0.10"
+default-features = false
+
+[dev-dependencies.sha2]
+version = "0.10"
+default-features = false
+
+[features]
+std = ["hmac/std"]
diff --git a/external/vendor/hkdf/Cargo.toml.orig b/external/vendor/hkdf/Cargo.toml.orig
new file mode 100644
index 0000000..b591ee9
--- /dev/null
+++ b/external/vendor/hkdf/Cargo.toml.orig
@@ -0,0 +1,28 @@
+[package]
+name = "hkdf"
+version = "0.12.4"
+authors = ["RustCrypto Developers"]
+license = "MIT OR Apache-2.0"
+homepage = "https://github.com/RustCrypto/KDFs/"
+repository = "https://github.com/RustCrypto/KDFs/"
+description = "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)"
+keywords = ["crypto", "HKDF", "KDF"]
+categories = ["cryptography", "no-std"]
+readme = "README.md"
+edition = "2018"
+
+[dependencies]
+hmac = "0.12.1"
+
+[dev-dependencies]
+blobby = "0.3"
+hex-literal = "0.2.2"
+sha1 = { version = "0.10", default-features = false }
+sha2 = { version = "0.10", default-features = false }
+
+[features]
+std = ["hmac/std"]
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
diff --git a/external/vendor/hkdf/LICENSE-APACHE b/external/vendor/hkdf/LICENSE-APACHE
new file mode 100644
index 0000000..53b7ccd
--- /dev/null
+++ b/external/vendor/hkdf/LICENSE-APACHE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
\ No newline at end of file
diff --git a/external/vendor/hkdf/LICENSE-MIT b/external/vendor/hkdf/LICENSE-MIT
new file mode 100644
index 0000000..c0d0781
--- /dev/null
+++ b/external/vendor/hkdf/LICENSE-MIT
@@ -0,0 +1,26 @@
+Copyright (c) 2015-2018 Vlad Filippov
+Copyright (c) 2018-2021 RustCrypto Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
diff --git a/external/vendor/hkdf/README.md b/external/vendor/hkdf/README.md
new file mode 100644
index 0000000..76efd86
--- /dev/null
+++ b/external/vendor/hkdf/README.md
@@ -0,0 +1,85 @@
+# RustCrypto: HKDF
+
+[![crate][crate-image]][crate-link]
+[![Docs][docs-image]][docs-link]
+![Apache2/MIT licensed][license-image]
+![Rust Version][rustc-image]
+[![Project Chat][chat-image]][chat-link]
+[![Build Status][build-image]][build-link]
+
+Pure Rust implementation of the [HMAC-based Extract-and-Expand Key Derivation Function (HKDF)](https://tools.ietf.org/html/rfc5869) generic over hash function.
+
+# Usage
+
+The most common way to use HKDF is as follows: you provide the Initial Key Material (IKM) and an optional salt, then you expand it (perhaps multiple times) into some Output Key Material (OKM) bound to an "info" context string.
+
+```rust
+use sha2::Sha256;
+use hkdf::Hkdf;
+use hex_literal::hex;
+
+let ikm = hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+let salt = hex!("000102030405060708090a0b0c");
+let info = hex!("f0f1f2f3f4f5f6f7f8f9");
+
+let hk = Hkdf::<Sha256>::new(Some(&salt[..]), &ikm);
+let mut okm = [0u8; 42];
+hk.expand(&info, &mut okm)
+ .expect("42 is a valid length for Sha256 to output");
+
+let expected = hex!("
+ 3cb25f25faacd57a90434f64d0362f2a
+ 2d2d0a90cf1a5a4c5db02d56ecc4c5bf
+ 34007208d5b887185865
+");
+assert_eq!(okm, expected);
+```
+
+Normally the PRK (Pseudo-Random Key) remains hidden within the HKDF object, but if you need to access it, use `Hkdf::extract` instead of `Hkdf::new`.
+
+```rust
+let (prk, hk) = Hkdf::<Sha256>::extract(Some(&salt[..]), &ikm);
+let expected = hex!("
+ 077709362c2e32df0ddc3f0dc47bba63
+ 90b6c73bb50f9c3122ec844ad7c2b3e5
+");
+assert_eq!(prk[..], expected[..]);
+```
+
+If you already have a strong key to work from (uniformly-distributed and
+long enough), you can save a tiny amount of time by skipping the extract
+step. In this case, you pass a Pseudo-Random Key (PRK) into the
+`Hkdf::from_prk` constructor, then use the resulting `Hkdf` object
+as usual.
+
+```rust
+let prk = hex!("
+ 077709362c2e32df0ddc3f0dc47bba63
+ 90b6c73bb50f9c3122ec844ad7c2b3e5
+");
+
+let hk = Hkdf::<Sha256>::from_prk(&prk).expect("PRK should be large enough");
+let mut okm = [0u8; 42];
+hk.expand(&info, &mut okm)
+ .expect("42 is a valid length for Sha256 to output");
+
+let expected = hex!("
+ 3cb25f25faacd57a90434f64d0362f2a
+ 2d2d0a90cf1a5a4c5db02d56ecc4c5bf
+ 34007208d5b887185865
+");
+assert_eq!(okm, expected);
+```
+
+[//]: # (badges)
+
+[crate-image]: https://img.shields.io/crates/v/hkdf.svg
+[crate-link]: https://crates.io/crates/hkdf
+[docs-image]: https://docs.rs/hkdf/badge.svg
+[docs-link]: https://docs.rs/hkdf/
+[license-image]: https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg
+[rustc-image]: https://img.shields.io/badge/rustc-1.41+-blue.svg
+[chat-image]: https://img.shields.io/badge/zulip-join_chat-blue.svg
+[chat-link]: https://rustcrypto.zulipchat.com/#narrow/stream/260043-KDFs
+[build-image]: https://github.com/RustCrypto/KDFs/workflows/hkdf/badge.svg?branch=master&event=push
+[build-link]: https://github.com/RustCrypto/KDFs/actions?query=workflow:hkdf
diff --git a/external/vendor/hkdf/benches/mod.rs b/external/vendor/hkdf/benches/mod.rs
new file mode 100644
index 0000000..4679b59
--- /dev/null
+++ b/external/vendor/hkdf/benches/mod.rs
@@ -0,0 +1,27 @@
+#![feature(test)]
+extern crate test;
+
+use test::Bencher;
+
+type HkdfSha256 = hkdf::Hkdf<sha2::Sha256>;
+
+#[bench]
+fn hkdf_sha256_10(b: &mut Bencher) {
+ let mut okm = vec![0u8; 10];
+ b.iter(|| HkdfSha256::new(Some(&[]), &[]).expand(&[], &mut okm));
+ b.bytes = okm.len() as u64;
+}
+
+#[bench]
+fn hkdf_sha256_1024(b: &mut Bencher) {
+ let mut okm = vec![0u8; 1024];
+ b.iter(|| HkdfSha256::new(Some(&[]), &[]).expand(&[], &mut okm));
+ b.bytes = okm.len() as u64;
+}
+
+#[bench]
+fn hkdf_sha256_8000(b: &mut Bencher) {
+ let mut okm = vec![0u8; 8000];
+ b.iter(|| HkdfSha256::new(Some(&[]), &[]).expand(&[], &mut okm));
+ b.bytes = okm.len() as u64;
+}
diff --git a/external/vendor/hkdf/src/errors.rs b/external/vendor/hkdf/src/errors.rs
new file mode 100644
index 0000000..e2109b4
--- /dev/null
+++ b/external/vendor/hkdf/src/errors.rs
@@ -0,0 +1,29 @@
+use core::fmt;
+
+/// Error that is returned when supplied pseudorandom key (PRK) is not long enough.
+#[derive(Copy, Clone, Debug)]
+pub struct InvalidPrkLength;
+
+impl fmt::Display for InvalidPrkLength {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
+ f.write_str("invalid pseudorandom key length, too short")
+ }
+}
+
+#[cfg(feature = "std")]
+#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
+impl ::std::error::Error for InvalidPrkLength {}
+
+/// Structure for InvalidLength, used for output error handling.
+#[derive(Copy, Clone, Debug)]
+pub struct InvalidLength;
+
+impl fmt::Display for InvalidLength {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
+ f.write_str("invalid number of blocks, too large output")
+ }
+}
+
+#[cfg(feature = "std")]
+#[cfg_attr(docsrs, doc(cfg(feature = "std")))]
+impl ::std::error::Error for InvalidLength {}
diff --git a/external/vendor/hkdf/src/lib.rs b/external/vendor/hkdf/src/lib.rs
new file mode 100644
index 0000000..3e4937f
--- /dev/null
+++ b/external/vendor/hkdf/src/lib.rs
@@ -0,0 +1,290 @@
+//! An implementation of HKDF, the [HMAC-based Extract-and-Expand Key Derivation Function][1].
+//!
+//! # Usage
+//!
+//! The most common way to use HKDF is as follows: you provide the Initial Key
+//! Material (IKM) and an optional salt, then you expand it (perhaps multiple times)
+//! into some Output Key Material (OKM) bound to an "info" context string.
+//!
+//! There are two usage options for the salt:
+//!
+//! - [`None`] or static for domain separation in a private setting
+//! - guaranteed to be uniformly-distributed and unique in a public setting
+//!
+//! Other non fitting data should be added to the `IKM` or `info`.
+//!
+//! ```rust
+//! use sha2::Sha256;
+//! use hkdf::Hkdf;
+//! use hex_literal::hex;
+//!
+//! let ikm = hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+//! let salt = hex!("000102030405060708090a0b0c");
+//! let info = hex!("f0f1f2f3f4f5f6f7f8f9");
+//!
+//! let hk = Hkdf::<Sha256>::new(Some(&salt[..]), &ikm);
+//! let mut okm = [0u8; 42];
+//! hk.expand(&info, &mut okm)
+//! .expect("42 is a valid length for Sha256 to output");
+//!
+//! let expected = hex!("
+//! 3cb25f25faacd57a90434f64d0362f2a
+//! 2d2d0a90cf1a5a4c5db02d56ecc4c5bf
+//! 34007208d5b887185865
+//! ");
+//! assert_eq!(okm[..], expected[..]);
+//! ```
+//!
+//! Normally the PRK (Pseudo-Random Key) remains hidden within the HKDF
+//! object, but if you need to access it, use [`Hkdf::extract`] instead of
+//! [`Hkdf::new`].
+//!
+//! ```rust
+//! # use sha2::Sha256;
+//! # use hkdf::Hkdf;
+//! # use hex_literal::hex;
+//! # let ikm = hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+//! # let salt = hex!("000102030405060708090a0b0c");
+//!
+//! let (prk, hk) = Hkdf::<Sha256>::extract(Some(&salt[..]), &ikm);
+//! let expected = hex!("
+//! 077709362c2e32df0ddc3f0dc47bba63
+//! 90b6c73bb50f9c3122ec844ad7c2b3e5
+//! ");
+//! assert_eq!(prk[..], expected[..]);
+//! ```
+//!
+//! If you already have a strong key to work from (uniformly-distributed and
+//! long enough), you can save a tiny amount of time by skipping the extract
+//! step. In this case, you pass a Pseudo-Random Key (PRK) into the
+//! [`Hkdf::from_prk`] constructor, then use the resulting [`Hkdf`] object
+//! as usual.
+//!
+//! ```rust
+//! # use sha2::Sha256;
+//! # use hkdf::Hkdf;
+//! # use hex_literal::hex;
+//! # let salt = hex!("000102030405060708090a0b0c");
+//! # let info = hex!("f0f1f2f3f4f5f6f7f8f9");
+//! let prk = hex!("
+//! 077709362c2e32df0ddc3f0dc47bba63
+//! 90b6c73bb50f9c3122ec844ad7c2b3e5
+//! ");
+//!
+//! let hk = Hkdf::<Sha256>::from_prk(&prk).expect("PRK should be large enough");
+//! let mut okm = [0u8; 42];
+//! hk.expand(&info, &mut okm)
+//! .expect("42 is a valid length for Sha256 to output");
+//!
+//! let expected = hex!("
+//! 3cb25f25faacd57a90434f64d0362f2a
+//! 2d2d0a90cf1a5a4c5db02d56ecc4c5bf
+//! 34007208d5b887185865
+//! ");
+//! assert_eq!(okm[..], expected[..]);
+//! ```
+//!
+//! [1]: https://tools.ietf.org/html/rfc5869
+
+#![no_std]
+#![doc(
+ html_logo_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg",
+ html_favicon_url = "https://raw.githubusercontent.com/RustCrypto/media/6ee8e381/logo.svg"
+)]
+#![cfg_attr(docsrs, feature(doc_cfg))]
+#![forbid(unsafe_code)]
+#![warn(missing_docs, rust_2018_idioms)]
+
+#[cfg(feature = "std")]
+extern crate std;
+
+pub use hmac;
+
+use core::fmt;
+use core::marker::PhantomData;
+use hmac::digest::{
+ crypto_common::AlgorithmName, generic_array::typenum::Unsigned, Output, OutputSizeUser,
+};
+use hmac::{Hmac, SimpleHmac};
+
+mod errors;
+mod sealed;
+
+pub use errors::{InvalidLength, InvalidPrkLength};
+
+/// [`HkdfExtract`] variant which uses [`SimpleHmac`] for underlying HMAC
+/// implementation.
+pub type SimpleHkdfExtract<H> = HkdfExtract<H, SimpleHmac<H>>;
+/// [`Hkdf`] variant which uses [`SimpleHmac`] for underlying HMAC
+/// implementation.
+pub type SimpleHkdf<H> = Hkdf<H, SimpleHmac<H>>;
+
+/// Structure representing the streaming context of an HKDF-Extract operation
+/// ```rust
+/// # use hkdf::{Hkdf, HkdfExtract};
+/// # use sha2::Sha256;
+/// let mut extract_ctx = HkdfExtract::<Sha256>::new(Some(b"mysalt"));
+/// extract_ctx.input_ikm(b"hello");
+/// extract_ctx.input_ikm(b" world");
+/// let (streamed_res, _) = extract_ctx.finalize();
+///
+/// let (oneshot_res, _) = Hkdf::<Sha256>::extract(Some(b"mysalt"), b"hello world");
+/// assert_eq!(streamed_res, oneshot_res);
+/// ```
+#[derive(Clone)]
+pub struct HkdfExtract<H, I = Hmac<H>>
+where
+ H: OutputSizeUser,
+ I: HmacImpl<H>,
+{
+ hmac: I,
+ _pd: PhantomData<H>,
+}
+
+impl<H, I> HkdfExtract<H, I>
+where
+ H: OutputSizeUser,
+ I: HmacImpl<H>,
+{
+ /// Initiates the HKDF-Extract context with the given optional salt
+ pub fn new(salt: Option<&[u8]>) -> Self {
+ let default_salt = Output::<H>::default();
+ let salt = salt.unwrap_or(&default_salt);
+ Self {
+ hmac: I::new_from_slice(salt),
+ _pd: PhantomData,
+ }
+ }
+
+ /// Feeds in additional input key material to the HKDF-Extract context
+ pub fn input_ikm(&mut self, ikm: &[u8]) {
+ self.hmac.update(ikm);
+ }
+
+ /// Completes the HKDF-Extract operation, returning both the generated pseudorandom key and
+ /// `Hkdf` struct for expanding.
+ pub fn finalize(self) -> (Output<H>, Hkdf<H, I>) {
+ let prk = self.hmac.finalize();
+ let hkdf = Hkdf::from_prk(&prk).expect("PRK size is correct");
+ (prk, hkdf)
+ }
+}
+
+impl<H, I> fmt::Debug for HkdfExtract<H, I>
+where
+ H: OutputSizeUser,
+ I: HmacImpl<H>,
+ I::Core: AlgorithmName,
+{
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("HkdfExtract<")?;
+ <I::Core as AlgorithmName>::write_alg_name(f)?;
+ f.write_str("> { ... }")
+ }
+}
+
+/// Structure representing the HKDF, capable of HKDF-Expand and HKDF-Extract operations.
+/// Recommendations for the correct usage of the parameters can be found in the
+/// [crate root](index.html#usage).
+#[derive(Clone)]
+pub struct Hkdf<H: OutputSizeUser, I: HmacImpl<H> = Hmac<H>> {
+ hmac: I::Core,
+ _pd: PhantomData<H>,
+}
+
+impl<H: OutputSizeUser, I: HmacImpl<H>> Hkdf<H, I> {
+ /// Convenience method for [`extract`][Hkdf::extract] when the generated
+ /// pseudorandom key can be ignored and only HKDF-Expand operation is needed. This is the most
+ /// common constructor.
+ pub fn new(salt: Option<&[u8]>, ikm: &[u8]) -> Self {
+ let (_, hkdf) = Self::extract(salt, ikm);
+ hkdf
+ }
+
+ /// Create `Hkdf` from an already cryptographically strong pseudorandom key
+ /// as per section 3.3 from RFC5869.
+ pub fn from_prk(prk: &[u8]) -> Result<Self, InvalidPrkLength> {
+ // section 2.3 specifies that prk must be "at least HashLen octets"
+ if prk.len() < <H as OutputSizeUser>::OutputSize::to_usize() {
+ return Err(InvalidPrkLength);
+ }
+ Ok(Self {
+ hmac: I::new_core(prk),
+ _pd: PhantomData,
+ })
+ }
+
+ /// The RFC5869 HKDF-Extract operation returning both the generated
+ /// pseudorandom key and `Hkdf` struct for expanding.
+ pub fn extract(salt: Option<&[u8]>, ikm: &[u8]) -> (Output<H>, Self) {
+ let mut extract_ctx = HkdfExtract::new(salt);
+ extract_ctx.input_ikm(ikm);
+ extract_ctx.finalize()
+ }
+
+ /// The RFC5869 HKDF-Expand operation. This is equivalent to calling
+ /// [`expand`][Hkdf::extract] with the `info` argument set equal to the
+ /// concatenation of all the elements of `info_components`.
+ pub fn expand_multi_info(
+ &self,
+ info_components: &[&[u8]],
+ okm: &mut [u8],
+ ) -> Result<(), InvalidLength> {
+ let mut prev: Option<Output<H>> = None;
+
+ let chunk_len = <H as OutputSizeUser>::OutputSize::USIZE;
+ if okm.len() > chunk_len * 255 {
+ return Err(InvalidLength);
+ }
+
+ for (block_n, block) in okm.chunks_mut(chunk_len).enumerate() {
+ let mut hmac = I::from_core(&self.hmac);
+
+ if let Some(ref prev) = prev {
+ hmac.update(prev)
+ };
+
+ // Feed in the info components in sequence. This is equivalent to feeding in the
+ // concatenation of all the info components
+ for info in info_components {
+ hmac.update(info);
+ }
+
+ hmac.update(&[block_n as u8 + 1]);
+
+ let output = hmac.finalize();
+
+ let block_len = block.len();
+ block.copy_from_slice(&output[..block_len]);
+
+ prev = Some(output);
+ }
+
+ Ok(())
+ }
+
+ /// The RFC5869 HKDF-Expand operation
+ ///
+ /// If you don't have any `info` to pass, use an empty slice.
+ pub fn expand(&self, info: &[u8], okm: &mut [u8]) -> Result<(), InvalidLength> {
+ self.expand_multi_info(&[info], okm)
+ }
+}
+
+impl<H, I> fmt::Debug for Hkdf<H, I>
+where
+ H: OutputSizeUser,
+ I: HmacImpl<H>,
+ I::Core: AlgorithmName,
+{
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("Hkdf<")?;
+ <I::Core as AlgorithmName>::write_alg_name(f)?;
+ f.write_str("> { ... }")
+ }
+}
+
+/// Sealed trait implemented for [`Hmac`] and [`SimpleHmac`].
+pub trait HmacImpl<H: OutputSizeUser>: sealed::Sealed<H> {}
+
+impl<H: OutputSizeUser, T: sealed::Sealed<H>> HmacImpl<H> for T {}
diff --git a/external/vendor/hkdf/src/sealed.rs b/external/vendor/hkdf/src/sealed.rs
new file mode 100644
index 0000000..5a2ec62
--- /dev/null
+++ b/external/vendor/hkdf/src/sealed.rs
@@ -0,0 +1,97 @@
+use hmac::digest::{
+ block_buffer::Eager,
+ core_api::{
+ BlockSizeUser, BufferKindUser, CoreProxy, CoreWrapper, FixedOutputCore, OutputSizeUser,
+ UpdateCore,
+ },
+ generic_array::typenum::{IsLess, Le, NonZero, U256},
+ Digest, FixedOutput, HashMarker, KeyInit, Output, Update,
+};
+use hmac::{Hmac, HmacCore, SimpleHmac};
+
+pub trait Sealed<H: OutputSizeUser> {
+ type Core: Clone;
+
+ fn new_from_slice(key: &[u8]) -> Self;
+
+ fn new_core(key: &[u8]) -> Self::Core;
+
+ fn from_core(core: &Self::Core) -> Self;
+
+ fn update(&mut self, data: &[u8]);
+
+ fn finalize(self) -> Output<H>;
+}
+
+impl<H> Sealed<H> for Hmac<H>
+where
+ H: CoreProxy + OutputSizeUser,
+ H::Core: HashMarker
+ + UpdateCore
+ + FixedOutputCore
+ + BufferKindUser<BufferKind = Eager>
+ + Default
+ + Clone,
+ <H::Core as BlockSizeUser>::BlockSize: IsLess<U256>,
+ Le<<H::Core as BlockSizeUser>::BlockSize, U256>: NonZero,
+{
+ type Core = HmacCore<H>;
+
+ #[inline(always)]
+ fn new_from_slice(key: &[u8]) -> Self {
+ KeyInit::new_from_slice(key).expect("HMAC can take a key of any size")
+ }
+
+ #[inline(always)]
+ fn new_core(key: &[u8]) -> Self::Core {
+ HmacCore::new_from_slice(key).expect("HMAC can take a key of any size")
+ }
+
+ #[inline(always)]
+ fn from_core(core: &Self::Core) -> Self {
+ CoreWrapper::from_core(core.clone())
+ }
+
+ #[inline(always)]
+ fn update(&mut self, data: &[u8]) {
+ Update::update(self, data);
+ }
+
+ #[inline(always)]
+ fn finalize(self) -> Output<H> {
+ // Output<H> and Output<H::Core> are always equal to each other,
+ // but we can not prove it at type level
+ Output::<H>::clone_from_slice(&self.finalize_fixed())
+ }
+}
+
+impl<H: Digest + BlockSizeUser + Clone> Sealed<H> for SimpleHmac<H> {
+ type Core = Self;
+
+ #[inline(always)]
+ fn new_from_slice(key: &[u8]) -> Self {
+ KeyInit::new_from_slice(key).expect("HMAC can take a key of any size")
+ }
+
+ #[inline(always)]
+ fn new_core(key: &[u8]) -> Self::Core {
+ KeyInit::new_from_slice(key).expect("HMAC can take a key of any size")
+ }
+
+ #[inline(always)]
+ fn from_core(core: &Self::Core) -> Self {
+ core.clone()
+ }
+
+ #[inline(always)]
+ fn update(&mut self, data: &[u8]) {
+ Update::update(self, data);
+ }
+
+ #[inline(always)]
+ fn finalize(self) -> Output<H> {
+ // Output<H> and Output<H::Core> are always equal to each other,
+ // but we can not prove it at type level
+ Output::<H>::clone_from_slice(&self.finalize_fixed())
+ }
+}
diff --git a/external/vendor/hkdf/tests/data/wycheproof-sha1.blb b/external/vendor/hkdf/tests/data/wycheproof-sha1.blb
new file mode 100644
index 0000000..cb7dd3c
Binary files /dev/null and b/external/vendor/hkdf/tests/data/wycheproof-sha1.blb differ
diff --git a/external/vendor/hkdf/tests/data/wycheproof-sha256.blb b/external/vendor/hkdf/tests/data/wycheproof-sha256.blb
new file mode 100644
index 0000000..6213609
Binary files /dev/null and b/external/vendor/hkdf/tests/data/wycheproof-sha256.blb differ
diff --git a/external/vendor/hkdf/tests/data/wycheproof-sha384.blb b/external/vendor/hkdf/tests/data/wycheproof-sha384.blb
new file mode 100644
index 0000000..2323055
Binary files /dev/null and b/external/vendor/hkdf/tests/data/wycheproof-sha384.blb differ
diff --git a/external/vendor/hkdf/tests/data/wycheproof-sha512.blb b/external/vendor/hkdf/tests/data/wycheproof-sha512.blb
new file mode 100644
index 0000000..7a75318
Binary files /dev/null and b/external/vendor/hkdf/tests/data/wycheproof-sha512.blb differ
diff --git a/external/vendor/hkdf/tests/tests.rs b/external/vendor/hkdf/tests/tests.rs
new file mode 100644
index 0000000..e6b6bca
--- /dev/null
+++ b/external/vendor/hkdf/tests/tests.rs
@@ -0,0 +1,455 @@
+use core::iter;
+
+use hex_literal::hex;
+use hkdf::{Hkdf, HkdfExtract, SimpleHkdf, SimpleHkdfExtract};
+use sha1::Sha1;
+use sha2::{Sha256, Sha384, Sha512};
+
+struct Test<'a> {
+ ikm: &'a [u8],
+ salt: &'a [u8],
+ info: &'a [u8],
+ prk: &'a [u8],
+ okm: &'a [u8],
+}
+
+// Test Vectors from https://tools.ietf.org/html/rfc5869.
+#[test]
+#[rustfmt::skip]
+fn test_rfc5869_sha256() {
+ let tests = [
+ Test {
+ // Test Case 1
+ ikm: &hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
+ salt: &hex!("000102030405060708090a0b0c"),
+ info: &hex!("f0f1f2f3f4f5f6f7f8f9"),
+ prk: &hex!("
+ 077709362c2e32df0ddc3f0dc47bba63
+ 90b6c73bb50f9c3122ec844ad7c2b3e5
+ "),
+ okm: &hex!("
+ 3cb25f25faacd57a90434f64d0362f2a
+ 2d2d0a90cf1a5a4c5db02d56ecc4c5bf
+ 34007208d5b887185865
+ "),
+ },
+ Test {
+ // Test Case 2
+ ikm: &hex!("
+ 000102030405060708090a0b0c0d0e0f
+ 101112131415161718191a1b1c1d1e1f
+ 202122232425262728292a2b2c2d2e2f
+ 303132333435363738393a3b3c3d3e3f
+ 404142434445464748494a4b4c4d4e4f
+ "),
+ salt: &hex!("
+ 606162636465666768696a6b6c6d6e6f
+ 707172737475767778797a7b7c7d7e7f
+ 808182838485868788898a8b8c8d8e8f
+ 909192939495969798999a9b9c9d9e9f
+ a0a1a2a3a4a5a6a7a8a9aaabacadaeaf
+ "),
+ info: &hex!("
+ b0b1b2b3b4b5b6b7b8b9babbbcbdbebf
+ c0c1c2c3c4c5c6c7c8c9cacbcccdcecf
+ d0d1d2d3d4d5d6d7d8d9dadbdcdddedf
+ e0e1e2e3e4e5e6e7e8e9eaebecedeeef
+ f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
+ "),
+ prk: &hex!("
+ 06a6b88c5853361a06104c9ceb35b45c
+ ef760014904671014a193f40c15fc244
+ "),
+ okm: &hex!("
+ b11e398dc80327a1c8e7f78c596a4934
+ 4f012eda2d4efad8a050cc4c19afa97c
+ 59045a99cac7827271cb41c65e590e09
+ da3275600c2f09b8367793a9aca3db71
+ cc30c58179ec3e87c14c01d5c1f3434f
+ 1d87
+ "),
+ },
+ Test {
+ // Test Case 3
+ ikm: &hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
+ salt: &hex!(""),
+ info: &hex!(""),
+ prk: &hex!("
+ 19ef24a32c717b167f33a91d6f648bdf
+ 96596776afdb6377ac434c1c293ccb04
+ "),
+ okm: &hex!("
+ 8da4e775a563c18f715f802a063c5a31
+ b8a11f5c5ee1879ec3454e5f3c738d2d
+ 9d201395faa4b61a96c8
+ "),
+ },
+ ];
+ for Test { ikm, salt, info, prk, okm } in tests.iter() {
+ let salt = if salt.is_empty() {
+ None
+ } else {
+ Some(&salt[..])
+ };
+ let (prk2, hkdf) = Hkdf::<Sha256>::extract(salt, ikm);
+ let mut okm2 = vec![0u8; okm.len()];
+ assert!(hkdf.expand(&info[..], &mut okm2).is_ok());
+
+ assert_eq!(prk2[..], prk[..]);
+ assert_eq!(okm2[..], okm[..]);
+
+ okm2.iter_mut().for_each(|b| *b = 0);
+ let hkdf = Hkdf::<Sha256>::from_prk(prk).unwrap();
+ assert!(hkdf.expand(&info[..], &mut okm2).is_ok());
+ assert_eq!(okm2[..], okm[..]);
+ }
+}
+
+#[test]
+#[rustfmt::skip]
+fn test_rfc5869_sha1() {
+ let tests = [
+ Test {
+ // Test Case 4
+ ikm: &hex!("0b0b0b0b0b0b0b0b0b0b0b"),
+ salt: &hex!("000102030405060708090a0b0c"),
+ info: &hex!("f0f1f2f3f4f5f6f7f8f9"),
+ prk: &hex!("9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243"),
+ okm: &hex!("
+ 085a01ea1b10f36933068b56efa5ad81
+ a4f14b822f5b091568a9cdd4f155fda2
+ c22e422478d305f3f896
+ "),
+ },
+ Test {
+ // Test Case 5
+ ikm: &hex!("
+ 000102030405060708090a0b0c0d0e0f
+ 101112131415161718191a1b1c1d1e1f
+ 202122232425262728292a2b2c2d2e2f
+ 303132333435363738393a3b3c3d3e3f
+ 404142434445464748494a4b4c4d4e4f
+ "),
+ salt: &hex!("
+ 606162636465666768696a6b6c6d6e6f
+ 707172737475767778797a7b7c7d7e7f
+ 808182838485868788898a8b8c8d8e8f
+ 909192939495969798999a9b9c9d9e9f
+ a0a1a2a3a4a5a6a7a8a9aaabacadaeaf
+ "),
+ info: &hex!("
+ b0b1b2b3b4b5b6b7b8b9babbbcbdbebf
+ c0c1c2c3c4c5c6c7c8c9cacbcccdcecf
+ d0d1d2d3d4d5d6d7d8d9dadbdcdddedf
+ e0e1e2e3e4e5e6e7e8e9eaebecedeeef
+ f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
+ "),
+ prk: &hex!("8adae09a2a307059478d309b26c4115a224cfaf6"),
+ okm: &hex!("
+ 0bd770a74d1160f7c9f12cd5912a06eb
+ ff6adcae899d92191fe4305673ba2ffe
+ 8fa3f1a4e5ad79f3f334b3b202b2173c
+ 486ea37ce3d397ed034c7f9dfeb15c5e
+ 927336d0441f4c4300e2cff0d0900b52
+ d3b4
+ "),
+ },
+ Test {
+ // Test Case 6
+ ikm: &hex!("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
+ salt: &hex!(""),
+ info: &hex!(""),
+ prk: &hex!("da8c8a73c7fa77288ec6f5e7c297786aa0d32d01"),
+ okm: &hex!("
+ 0ac1af7002b3d761d1e55298da9d0506
+ b9ae52057220a306e07b6b87e8df21d0
+ ea00033de03984d34918
+ "),
+ },
+ Test {
+ // Test Case 7
+ ikm: &hex!("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"),
+ salt: &hex!(""), // "Not Provided"
+ info: &hex!(""),
+ prk: &hex!("2adccada18779e7c2077ad2eb19d3f3e731385dd"),
+ okm: &hex!("
+ 2c91117204d745f3500d636a62f64f0a
+ b3bae548aa53d423b0d1f27ebba6f5e5
+ 673a081d70cce7acfc48
+ "),
+ },
+ ];
+ for Test { ikm, salt, info, prk, okm } in tests.iter() {
+ let salt = if salt.is_empty() {
+ None
+ } else {
+ Some(&salt[..])
+ };
+ let (prk2, hkdf) = Hkdf::<Sha1>::extract(salt, ikm);
+ let mut okm2 = vec![0u8; okm.len()];
+ assert!(hkdf.expand(&info[..], &mut okm2).is_ok());
+
+ assert_eq!(prk2[..], prk[..]);
+ assert_eq!(okm2[..], okm[..]);
+
+ okm2.iter_mut().for_each(|b| *b = 0);
+ let hkdf = Hkdf::<Sha1>::from_prk(prk).unwrap();
+ assert!(hkdf.expand(&info[..], &mut okm2).is_ok());
+ assert_eq!(okm2[..], okm[..]);
+ }
+}
+
+const MAX_SHA256_LENGTH: usize = 255 * (256 / 8); // =8160
+
+#[test]
+fn test_lengths() {
+ let hkdf = Hkdf::<Sha256>::new(None, &[]);
+ let mut longest = vec![0u8; MAX_SHA256_LENGTH];
+ assert!(hkdf.expand(&[], &mut longest).is_ok());
+ // Runtime is O(length), so exhaustively testing all legal lengths
+ // would take too long (at least without --release). Only test a
+ // subset: the first 500, the last 10, and every 100th in between.
+ let range = 500..MAX_SHA256_LENGTH - 10;
+ let lengths = (0..MAX_SHA256_LENGTH + 1).filter(|len| !range.contains(len) || *len % 100 == 0);
+
+ for length in lengths {
+ let mut okm = vec![0u8; length];
+ assert!(hkdf.expand(&[], &mut okm).is_ok());
+ assert_eq!(okm.len(), length);
+ assert_eq!(okm[..], longest[..length]);
+ }
+}
+
+#[test]
+fn test_max_length() {
+ let hkdf = Hkdf::<Sha256>::new(Some(&[]), &[]);
+ let mut okm = vec![0u8; MAX_SHA256_LENGTH];
+ assert!(hkdf.expand(&[], &mut okm).is_ok());
+}
+
+#[test]
+fn test_max_length_exceeded() {
+ let hkdf = Hkdf::<Sha256>::new(Some(&[]), &[]);
+ let mut okm = vec![0u8; MAX_SHA256_LENGTH + 1];
+ assert!(hkdf.expand(&[], &mut okm).is_err());
+}
+
+#[test]
+fn test_unsupported_length() {
+ let hkdf = Hkdf::<Sha256>::new(Some(&[]), &[]);
+ let mut okm = vec![0u8; 90000];
+ assert!(hkdf.expand(&[], &mut okm).is_err());
+}
+
+#[test]
+fn test_prk_too_short() {
+ use sha2::digest::Digest;
+
+ let output_len = Sha256::output_size();
+ let prk = vec![0; output_len - 1];
+ assert!(Hkdf::<Sha256>::from_prk(&prk).is_err());
+}
+
+#[test]
+#[rustfmt::skip]
+fn test_derive_sha1_with_none() {
+ let ikm = hex!("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c");
+ let salt = None;
+ let info = hex!("");
+ let (prk, hkdf) = Hkdf::<Sha1>::extract(salt, &ikm[..]);
+ let mut okm = [0u8; 42];
+ assert!(hkdf.expand(&info[..], &mut okm).is_ok());
+
+ assert_eq!(
+ prk[..],
+ hex!("2adccada18779e7c2077ad2eb19d3f3e731385dd")[..]
+ );
+ assert_eq!(
+ okm[..],
+ hex!("
+ 2c91117204d745f3500d636a62f64f0a
+ b3bae548aa53d423b0d1f27ebba6f5e5
+ 673a081d70cce7acfc48
+ ")[..],
+ );
+}
+
+#[test]
+fn test_expand_multi_info() {
+ let info_components = &[
+ &b"09090909090909090909090909090909090909090909"[..],
+ &b"8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a"[..],
+ &b"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"[..],
+ &b"4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4"[..],
+ &b"1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d"[..],
+ ];
+
+ let (_, hkdf_ctx) = Hkdf::<Sha256>::extract(None, b"some ikm here");
+
+ // Compute HKDF-Expand on the concatenation of all the info components
+ let mut oneshot_res = [0u8; 16];
+ hkdf_ctx
+ .expand(&info_components.concat(), &mut oneshot_res)
+ .unwrap();
+
+ // Now iteratively join the components of info_components until it's all 1 component. The value
+ // of HKDF-Expand should be the same throughout
+ let mut num_concatted = 0;
+ let mut info_head = Vec::new();
+
+ while num_concatted < info_components.len() {
+ info_head.extend(info_components[num_concatted]);
+
+ // Build the new input to be the info head followed by the remaining components
+ let input: Vec<&[u8]> = iter::once(info_head.as_slice())
+ .chain(info_components.iter().cloned().skip(num_concatted + 1))
+ .collect();
+
+ // Compute and compare to the one-shot answer
+ let mut multipart_res = [0u8; 16];
+ hkdf_ctx
+ .expand_multi_info(&input, &mut multipart_res)
+ .unwrap();
+ assert_eq!(multipart_res, oneshot_res);
+
+ num_concatted += 1;
+ }
+}
+
+#[test]
+fn test_extract_streaming() {
+ let ikm_components = &[
+ &b"09090909090909090909090909090909090909090909"[..],
+ &b"8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a8a"[..],
+ &b"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"[..],
+ &b"4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4"[..],
+ &b"1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d"[..],
+ ];
+ let salt = b"mysalt";
+
+ // Compute HKDF-Extract on the concatenation of all the IKM components
+ let (oneshot_res, _) = Hkdf::<Sha256>::extract(Some(&salt[..]), &ikm_components.concat());
+
+ // Now iteratively join the components of ikm_components until it's all 1 component. The value
+ // of HKDF-Extract should be the same throughout
+ let mut num_concatted = 0;
+ let mut ikm_head = Vec::new();
+
+ while num_concatted < ikm_components.len() {
+ ikm_head.extend(ikm_components[num_concatted]);
+
+ // Make a new extraction context and build the new input to be the IKM head followed by the
+ // remaining components
+ let mut extract_ctx = HkdfExtract::<Sha256>::new(Some(&salt[..]));
+ let input = iter::once(ikm_head.as_slice())
+ .chain(ikm_components.iter().cloned().skip(num_concatted + 1));
+
+ // Stream in the IKM input in the chunks specified
+ for ikm in input {
+ extract_ctx.input_ikm(ikm);
+ }
+
+ // Finalize and compare to the one-shot answer
+ let (multipart_res, _) = extract_ctx.finalize();
+ assert_eq!(multipart_res, oneshot_res);
+
+ num_concatted += 1;
+ }
+
+ let mut num_concatted = 0;
+ let mut ikm_head = Vec::new();
+
+ while num_concatted < ikm_components.len() {
+ ikm_head.extend(ikm_components[num_concatted]);
+
+ // Make a new extraction context and build the new input to be the IKM head followed by the
+ // remaining components
+ let mut extract_ctx = SimpleHkdfExtract::<Sha256>::new(Some(&salt[..]));
+ let input = iter::once(ikm_head.as_slice())
+ .chain(ikm_components.iter().cloned().skip(num_concatted + 1));
+
+ // Stream in the IKM input in the chunks specified
+ for ikm in input {
+ extract_ctx.input_ikm(ikm);
+ }
+
+ // Finalize and compare to the one-shot answer
+ let (multipart_res, _) = extract_ctx.finalize();
+ assert_eq!(multipart_res, oneshot_res);
+
+ num_concatted += 1;
+ }
+}
+
+/// Define test
+macro_rules! new_test {
+ ($name:ident, $test_name:expr, $hkdf:ty) => {
+ #[test]
+ fn $name() {
+ use blobby::Blob4Iterator;
+
+ fn run_test(ikm: &[u8], salt: &[u8], info: &[u8], okm: &[u8]) -> Option<&'static str> {
+ let prk = <$hkdf>::new(Some(salt), ikm);
+ let mut got_okm = vec![0; okm.len()];
+
+ if prk.expand(info, &mut got_okm).is_err() {
+ return Some("prk expand");
+ }
+ if got_okm != okm {
+ return Some("mismatch in okm");
+ }
+ None
+ }
+
+ let data = include_bytes!(concat!("data/", $test_name, ".blb"));
+
+ for (i, row) in Blob4Iterator::new(data).unwrap().enumerate() {
+ let [ikm, salt, info, okm] = row.unwrap();
+ if let Some(desc) = run_test(ikm, salt, info, okm) {
+ panic!(
+ "\n\
+ Failed test №{}: {}\n\
+ ikm:\t{:?}\n\
+ salt:\t{:?}\n\
+ info:\t{:?}\n\
+ okm:\t{:?}\n",
+ i, desc, ikm, salt, info, okm
+ );
+ }
+ }
+ }
+ };
+}
+
+new_test!(wycheproof_sha1, "wycheproof-sha1", Hkdf::<Sha1>);
+new_test!(wycheproof_sha256, "wycheproof-sha256", Hkdf::<Sha256>);
+new_test!(wycheproof_sha384, "wycheproof-sha384", Hkdf::<Sha384>);
+new_test!(wycheproof_sha512, "wycheproof-sha512", Hkdf::<Sha512>);
+
+new_test!(
+ wycheproof_sha1_simple,
+ "wycheproof-sha1",
+ SimpleHkdf::<Sha1>
+);
+new_test!(
+ wycheproof_sha256_simple,
+ "wycheproof-sha256",
+ SimpleHkdf::<Sha256>
+);
+new_test!(
+ wycheproof_sha384_simple,
+ "wycheproof-sha384",
+ SimpleHkdf::<Sha384>
+);
+new_test!(
+ wycheproof_sha512_simple,
+ "wycheproof-sha512",
+ SimpleHkdf::<Sha512>
+);
+
+#[test]
+fn test_debug_impls() {
+ fn needs_debug<T: std::fmt::Debug>() {}
+ needs_debug::<Hkdf<Sha256>>();
+ needs_debug::<HkdfExtract<Sha256>>();
+}
diff --git a/messages/bitboxsync.proto b/messages/bitboxsync.proto
new file mode 100644
index 0000000..6c0e8f1
--- /dev/null
+++ b/messages/bitboxsync.proto
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: Apache-2.0
+
+syntax = "proto3";
+package shiftcrypto.bitbox02;
+
+message BitBoxSyncIdentityRequest {}
+
+message BitBoxSyncIdentityResponse {
+ bytes auth_public_key = 1;
+ bytes wrap_public_key = 2;
+}
+
+message BitBoxSyncSignLoginIntentRequest {
+ bytes challenge = 1;
+}
+
+message BitBoxSyncSignRefreshIntentRequest {
+ bytes challenge = 1;
+}
+
+message BitBoxSyncSignRevokeAllTokensIntentRequest {
+ bytes challenge = 1;
+}
+
+message BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ bytes challenge = 1;
+ bytes namespace_id = 2;
+ bytes invite_id = 3;
+ bytes invite_server_secret_hash = 4;
+ uint64 expires_at = 5;
+ uint32 max_accepted = 6;
+}
+
+message BitBoxSyncSignJoinRequestIntentRequest {
+ bytes namespace_id = 1;
+ bytes invite_id = 2;
+ string server_origin = 3;
+ uint64 expires_at = 4;
+}
+
+message BitBoxSyncUnwrapNamespaceDEKRequest {
+ bytes namespace_id = 1;
+ bytes wrapped_dek = 2;
+}
+
+message BitBoxSyncSignatureResponse {
+ bytes signature = 1;
+}
+
+message BitBoxSyncUnwrapNamespaceDEKResponse {
+ bytes namespace_dek = 1;
+}
+
+message BitBoxSyncRequest {
+ oneof request {
+ BitBoxSyncIdentityRequest identity = 1;
+ BitBoxSyncSignLoginIntentRequest sign_login_intent = 2;
+ BitBoxSyncSignRefreshIntentRequest sign_refresh_intent = 3;
+ BitBoxSyncSignRevokeAllTokensIntentRequest sign_revoke_all_tokens_intent = 4;
+ BitBoxSyncSignCreateNamespaceInviteIntentRequest sign_create_namespace_invite_intent = 5;
+ BitBoxSyncSignJoinRequestIntentRequest sign_join_request_intent = 6;
+ BitBoxSyncUnwrapNamespaceDEKRequest unwrap_namespace_dek = 7;
+ }
+}
+
+message BitBoxSyncResponse {
+ oneof response {
+ BitBoxSyncIdentityResponse identity = 1;
+ BitBoxSyncSignatureResponse signature = 2;
+ BitBoxSyncUnwrapNamespaceDEKResponse unwrap_namespace_dek = 3;
+ }
+}
diff --git a/messages/hww.proto b/messages/hww.proto
index 07cb660..39c7a05 100644
--- a/messages/hww.proto
+++ b/messages/hww.proto
@@ -7,6 +7,7 @@ import "common.proto";
import "backup_commands.proto";
import "bitbox02_system.proto";
+import "bitboxsync.proto";
import "bluetooth.proto";
import "btc.proto";
import "cardano.proto";
@@ -58,6 +59,7 @@ message Request {
BIP85Request bip85 = 28;
BluetoothRequest bluetooth = 29;
ChangePasswordRequest change_password = 30;
+ BitBoxSyncRequest bitbox_sync = 31;
}
}
@@ -81,5 +83,6 @@ message Response {
CardanoResponse cardano = 15;
BIP85Response bip85 = 16;
BluetoothResponse bluetooth = 17;
+ BitBoxSyncResponse bitbox_sync = 18;
}
}
diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock
index f2b0b45..8031b04 100644
--- a/src/rust/Cargo.lock
+++ b/src/rust/Cargo.lock
@@ -312,6 +312,7 @@ dependencies = [
"bitcoin",
"bitcoin_hashes",
"blake2",
+ "chacha20poly1305",
"crc",
"digest",
"ed25519-dalek",
@@ -319,6 +320,7 @@ dependencies = [
"futures-lite",
"hex",
"hex_lit",
+ "hkdf",
"hmac",
"keccak",
"minicbor",
@@ -332,6 +334,7 @@ dependencies = [
"sha3",
"streaming-silent-payments",
"util",
+ "x25519-dalek",
"zeroize",
]
@@ -628,6 +631,7 @@ dependencies = [
"fiat-crypto",
"rustc_version 0.4.0",
"subtle",
+ "zeroize",
]
[[package]]
@@ -705,6 +709,7 @@ dependencies = [
"sha2",
"signature",
"subtle",
+ "zeroize",
]
[[package]]
@@ -845,6 +850,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -1470,6 +1484,7 @@ checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96"
dependencies = [
"curve25519-dalek",
"rand_core",
+ "zeroize",
]
[[package]]
diff --git a/src/rust/bitbox-proto/src/generated/shiftcrypto.bitbox02.rs b/src/rust/bitbox-proto/src/generated/shiftcrypto.bitbox02.rs
index 4cddb3a..7a401b9 100644
--- a/src/rust/bitbox-proto/src/generated/shiftcrypto.bitbox02.rs
+++ b/src/rust/bitbox-proto/src/generated/shiftcrypto.bitbox02.rs
@@ -217,6 +217,129 @@ pub struct SetPasswordRequest {
pub struct ChangePasswordRequest {}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncIdentityRequest {}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncIdentityResponse {
+ #[prost(bytes = "vec", tag = "1")]
+ pub auth_public_key: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "2")]
+ pub wrap_public_key: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignLoginIntentRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub challenge: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignRefreshIntentRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub challenge: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignRevokeAllTokensIntentRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub challenge: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub challenge: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "2")]
+ pub namespace_id: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "3")]
+ pub invite_id: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "4")]
+ pub invite_server_secret_hash: ::prost::alloc::vec::Vec<u8>,
+ #[prost(uint64, tag = "5")]
+ pub expires_at: u64,
+ #[prost(uint32, tag = "6")]
+ pub max_accepted: u32,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignJoinRequestIntentRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub namespace_id: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "2")]
+ pub invite_id: ::prost::alloc::vec::Vec<u8>,
+ #[prost(string, tag = "3")]
+ pub server_origin: ::prost::alloc::string::String,
+ #[prost(uint64, tag = "4")]
+ pub expires_at: u64,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncUnwrapNamespaceDekRequest {
+ #[prost(bytes = "vec", tag = "1")]
+ pub namespace_id: ::prost::alloc::vec::Vec<u8>,
+ #[prost(bytes = "vec", tag = "2")]
+ pub wrapped_dek: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncSignatureResponse {
+ #[prost(bytes = "vec", tag = "1")]
+ pub signature: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncUnwrapNamespaceDekResponse {
+ #[prost(bytes = "vec", tag = "1")]
+ pub namespace_dek: ::prost::alloc::vec::Vec<u8>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncRequest {
+ #[prost(oneof = "bit_box_sync_request::Request", tags = "1, 2, 3, 4, 5, 6, 7")]
+ pub request: ::core::option::Option<bit_box_sync_request::Request>,
+}
+/// Nested message and enum types in `BitBoxSyncRequest`.
+pub mod bit_box_sync_request {
+ #[allow(clippy::derive_partial_eq_without_eq)]
+ #[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Request {
+ #[prost(message, tag = "1")]
+ Identity(super::BitBoxSyncIdentityRequest),
+ #[prost(message, tag = "2")]
+ SignLoginIntent(super::BitBoxSyncSignLoginIntentRequest),
+ #[prost(message, tag = "3")]
+ SignRefreshIntent(super::BitBoxSyncSignRefreshIntentRequest),
+ #[prost(message, tag = "4")]
+ SignRevokeAllTokensIntent(super::BitBoxSyncSignRevokeAllTokensIntentRequest),
+ #[prost(message, tag = "5")]
+ SignCreateNamespaceInviteIntent(super::BitBoxSyncSignCreateNamespaceInviteIntentRequest),
+ #[prost(message, tag = "6")]
+ SignJoinRequestIntent(super::BitBoxSyncSignJoinRequestIntentRequest),
+ #[prost(message, tag = "7")]
+ UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekRequest),
+ }
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct BitBoxSyncResponse {
+ #[prost(oneof = "bit_box_sync_response::Response", tags = "1, 2, 3")]
+ pub response: ::core::option::Option<bit_box_sync_response::Response>,
+}
+/// Nested message and enum types in `BitBoxSyncResponse`.
+pub mod bit_box_sync_response {
+ #[allow(clippy::derive_partial_eq_without_eq)]
+ #[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Response {
+ #[prost(message, tag = "1")]
+ Identity(super::BitBoxSyncIdentityResponse),
+ #[prost(message, tag = "2")]
+ Signature(super::BitBoxSyncSignatureResponse),
+ #[prost(message, tag = "3")]
+ UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekResponse),
+ }
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, Copy, PartialEq, ::prost::Message)]
pub struct BluetoothToggleEnabledRequest {}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
@@ -1930,7 +2053,7 @@ pub struct Success {}
pub struct Request {
#[prost(
oneof = "request::Request",
- tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30"
+ tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30, 31"
)]
pub request: ::core::option::Option<request::Request>,
}
@@ -1997,6 +2120,8 @@ pub mod request {
Bluetooth(super::BluetoothRequest),
#[prost(message, tag = "30")]
ChangePassword(super::ChangePasswordRequest),
+ #[prost(message, tag = "31")]
+ BitboxSync(super::BitBoxSyncRequest),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2004,7 +2129,7 @@ pub mod request {
pub struct Response {
#[prost(
oneof = "response::Response",
- tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17"
+ tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18"
)]
pub response: ::core::option::Option<response::Response>,
}
@@ -2046,5 +2171,7 @@ pub mod response {
Bip85(super::Bip85Response),
#[prost(message, tag = "17")]
Bluetooth(super::BluetoothResponse),
+ #[prost(message, tag = "18")]
+ BitboxSync(super::BitBoxSyncResponse),
}
}
diff --git a/src/rust/bitbox02-rust/Cargo.toml b/src/rust/bitbox02-rust/Cargo.toml
index e844e05..f0b919e 100644
--- a/src/rust/bitbox02-rust/Cargo.toml
+++ b/src/rust/bitbox02-rust/Cargo.toml
@@ -40,12 +40,15 @@ zeroize = { workspace = true }
num-bigint = { workspace = true, optional = true }
num-traits = { version = "0.2", default-features = false }
# If you change this, also change src/rust/.cargo/config.toml.
-bip32-ed25519 = { git = "https://github.com/BitBoxSwiss/rust-bip32-ed25519", tag = "v0.2.1", optional = true }
+bip32-ed25519 = { git = "https://github.com/BitBoxSwiss/rust-bip32-ed25519", tag = "v0.2.1" }
blake2 = { version = "0.10.6", default-features = false, features = ["size_opt"], optional = true }
minicbor = { version = "0.24.0", default-features = false, features = ["alloc"], optional = true }
crc = { workspace = true, optional = true }
-ed25519-dalek = { version = "2.1.1", default-features = false, features = ["hazmat", "digest"], optional = true }
+ed25519-dalek = { version = "2.1.1", default-features = false, features = ["hazmat", "digest", "zeroize"] }
hmac = { workspace = true }
+hkdf = { version = "0.12.4", default-features = false }
+chacha20poly1305 = { version = "0.10.1", default-features = false }
+x25519-dalek = { version = "2.0.0", default-features = false, features = ["static_secrets", "zeroize"] }
miniscript = { version = "13.0.0", default-features = false, features = [], optional = true }
bitcoin = { workspace = true }
@@ -64,11 +67,6 @@ default-features = false
features = ["derive"]
[features]
-ed25519 = [
- "dep:bip32-ed25519",
- "dep:ed25519-dalek"
-]
-
app-ethereum = [
"dep:erc20_params",
"dep:sha3",
@@ -95,8 +93,7 @@ app-u2f = [
app-cardano = [
"dep:blake2",
"dep:minicbor",
- "dep:crc",
- "ed25519"
+ "dep:crc"
]
testing = [
diff --git a/src/rust/bitbox02-rust/src/hww/api.rs b/src/rust/bitbox02-rust/src/hww/api.rs
index df1ce58..8af9114 100644
--- a/src/rust/bitbox02-rust/src/hww/api.rs
+++ b/src/rust/bitbox02-rust/src/hww/api.rs
@@ -17,6 +17,7 @@ mod cardano;
mod backup;
mod bip85;
+mod bitboxsync;
mod bluetooth;
mod change_password;
mod device_info;
@@ -143,6 +144,7 @@ fn can_call(hal: &mut impl crate::hal::Hal, request: &Request) -> bool {
| Request::Reset(_)
| Request::Cardano(_)
| Request::Bip85(_)
+ | Request::BitboxSync(_)
| Request::ChangePassword(_) => {
matches!(state, State::InitializedAndUnlocked)
}
@@ -199,6 +201,7 @@ async fn process_api(hal: &mut impl crate::hal::Hal, request: &Request) -> Resul
#[cfg(not(feature = "app-cardano"))]
Request::Cardano(_) => Err(Error::Disabled),
Request::Bip85(request) => bip85::process(hal, request).await,
+ Request::BitboxSync(request) => bitboxsync::process(hal, request).await,
Request::Bluetooth(pb::BluetoothRequest {
request: Some(request),
}) => bluetooth::process_api(hal, request)
diff --git a/src/rust/bitbox02-rust/src/hww/api/bitboxsync.rs b/src/rust/bitbox02-rust/src/hww/api/bitboxsync.rs
new file mode 100644
index 0000000..9fe6b89
--- /dev/null
+++ b/src/rust/bitbox02-rust/src/hww/api/bitboxsync.rs
@@ -0,0 +1,1148 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use alloc::{format, string::String, vec::Vec};
+
+use super::Error;
+use super::pb;
+use crate::hal::Ui;
+use crate::hal::ui::ConfirmParams;
+use crate::keystore::ed25519;
+use chacha20poly1305::aead::{AeadInPlace, KeyInit};
+use ed25519_dalek::VerifyingKey;
+use hkdf::Hkdf;
+use pb::response::Response;
+use sha2::{Digest, Sha256};
+use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret};
+use zeroize::{Zeroize, Zeroizing};
+
+const AUTH_SEED_LABEL: &[u8] = b"bitboxsync-auth-ed25519-seed-v1";
+const WRAP_SEED_LABEL: &[u8] = b"bitboxsync-wrap-x25519-seed-v1";
+
+const INTENT_PREFIX: &[u8] = b"bitboxsync-intent";
+const JOIN_REQUEST_PREFIX: &[u8] = b"bitboxsync-join-request";
+const WRAP_DEK_INFO: &[u8] = b"bitboxsync-wrap-dek-v1";
+
+const KIND_CODE_KEYSTORE: u8 = 0x01;
+const INTENT_VERSION: u8 = 0x01;
+const JOIN_REQUEST_VERSION: u8 = 0x01;
+const WRAPPED_DEK_VERSION: u8 = 0x01;
+const SENSITIVE_ACTION_REVOKE_ALL_TOKENS: u8 = 0x01;
+const SENSITIVE_ACTION_CREATE_NAMESPACE_INVITE: u8 = 0x02;
+
+const CHALLENGE_LEN: usize = 32;
+const KEY_ID_LEN: usize = 32;
+const NAMESPACE_ID_LEN: usize = 16;
+const INVITE_ID_LEN: usize = 16;
+const INVITE_SERVER_SECRET_HASH_LEN: usize = 32;
+const NAMESPACE_DEK_LEN: usize = 32;
+const SERVER_ORIGIN_HASH_LEN: usize = 32;
+const MAX_SERVER_ORIGIN_LEN: usize = 128;
+const WRAPPED_DEK_LEN_V1: usize = 1 + 32 + NAMESPACE_ID_LEN + NAMESPACE_DEK_LEN + 16;
+const SIGNATURE_LEN: usize = 64;
+
+struct IdentityKeys {
+ auth_seed: Zeroizing<[u8; 32]>,
+ auth_public_key: [u8; 32],
+ wrap_secret_key: Zeroizing<[u8; 32]>,
+ wrap_public_key: [u8; 32],
+}
+
+async fn identity_keys(hal: &mut impl crate::hal::Hal) -> Result<IdentityKeys, Error> {
+ let identity_root_key = crate::keystore::bip85_bitboxsync(hal)
+ .await
+ .map_err(|_| Error::Generic)?;
+ let auth_seed = derive_labeled_key(&identity_root_key, AUTH_SEED_LABEL)?;
+ let auth_public_key =
+ VerifyingKey::from(&ed25519::expanded_secret_key_from_seed(&auth_seed)).to_bytes();
+
+ let wrap_secret_key = derive_labeled_key(&identity_root_key, WRAP_SEED_LABEL)?;
+ let wrap_secret = X25519StaticSecret::from(*wrap_secret_key);
+ let wrap_public_key = X25519PublicKey::from(&wrap_secret).to_bytes();
+
+ Ok(IdentityKeys {
+ auth_seed,
+ auth_public_key,
+ wrap_secret_key,
+ wrap_public_key,
+ })
+}
+
+fn derive_labeled_key(
+ identity_root_key: &[u8],
+ label: &[u8],
+) -> Result<Zeroizing<[u8; 32]>, Error> {
+ let hkdf = Hkdf::<Sha256>::new(None, identity_root_key);
+ let mut out = Zeroizing::new([0u8; 32]);
+ hkdf.expand(label, &mut *out).map_err(|_| Error::Generic)?;
+ Ok(out)
+}
+
+fn key_id(auth_public_key: &[u8; 32]) -> [u8; KEY_ID_LEN] {
+ Sha256::digest(auth_public_key).into()
+}
+
+fn login_intent(challenge: &[u8], keys: &IdentityKeys) -> Result<Vec<u8>, Error> {
+ if challenge.len() != CHALLENGE_LEN {
+ return Err(Error::InvalidInput);
+ }
+ let key_id = key_id(&keys.auth_public_key);
+ let mut out = Vec::with_capacity(
+ INTENT_PREFIX.len()
+ + 1
+ + 1
+ + CHALLENGE_LEN
+ + 1
+ + KEY_ID_LEN
+ + keys.auth_public_key.len()
+ + keys.wrap_public_key.len(),
+ );
+ out.extend_from_slice(INTENT_PREFIX);
+ out.extend_from_slice(&[INTENT_VERSION, 0x01]);
+ out.extend_from_slice(challenge);
+ out.push(KIND_CODE_KEYSTORE);
+ out.extend_from_slice(&key_id);
+ out.extend_from_slice(&keys.auth_public_key);
+ out.extend_from_slice(&keys.wrap_public_key);
+ Ok(out)
+}
+
+fn refresh_intent(challenge: &[u8], keys: &IdentityKeys) -> Result<Vec<u8>, Error> {
+ if challenge.len() != CHALLENGE_LEN {
+ return Err(Error::InvalidInput);
+ }
+ let key_id = key_id(&keys.auth_public_key);
+ let mut out = Vec::with_capacity(INTENT_PREFIX.len() + 1 + 1 + CHALLENGE_LEN + 1 + KEY_ID_LEN);
+ out.extend_from_slice(INTENT_PREFIX);
+ out.extend_from_slice(&[INTENT_VERSION, 0x02]);
+ out.extend_from_slice(challenge);
+ out.push(KIND_CODE_KEYSTORE);
+ out.extend_from_slice(&key_id);
+ Ok(out)
+}
+
+fn sensitive_action_intent(
+ challenge: &[u8],
+ action_code: u8,
+ action_fields: &[u8],
+ keys: &IdentityKeys,
+) -> Result<Vec<u8>, Error> {
+ if challenge.len() != CHALLENGE_LEN {
+ return Err(Error::InvalidInput);
+ }
+ let key_id = key_id(&keys.auth_public_key);
+ let mut out = Vec::with_capacity(
+ INTENT_PREFIX.len() + 1 + 1 + 1 + CHALLENGE_LEN + 1 + KEY_ID_LEN + action_fields.len(),
+ );
+ out.extend_from_slice(INTENT_PREFIX);
+ out.extend_from_slice(&[INTENT_VERSION, 0x03, action_code]);
+ out.extend_from_slice(challenge);
+ out.push(KIND_CODE_KEYSTORE);
+ out.extend_from_slice(&key_id);
+ out.extend_from_slice(action_fields);
+ Ok(out)
+}
+
+fn revoke_all_tokens_intent(challenge: &[u8], keys: &IdentityKeys) -> Result<Vec<u8>, Error> {
+ sensitive_action_intent(challenge, SENSITIVE_ACTION_REVOKE_ALL_TOKENS, &[], keys)
+}
+
+fn create_namespace_invite_action_fields(
+ namespace_id: &[u8],
+ invite_id: &[u8],
+ invite_server_secret_hash: &[u8],
+ expires_at: u64,
+ max_accepted: u32,
+) -> Result<Vec<u8>, Error> {
+ if namespace_id.len() != NAMESPACE_ID_LEN
+ || invite_id.len() != INVITE_ID_LEN
+ || invite_server_secret_hash.len() != INVITE_SERVER_SECRET_HASH_LEN
+ {
+ return Err(Error::InvalidInput);
+ }
+
+ let mut out = Vec::with_capacity(
+ NAMESPACE_ID_LEN + INVITE_ID_LEN + INVITE_SERVER_SECRET_HASH_LEN + 8 + 4,
+ );
+ out.extend_from_slice(namespace_id);
+ out.extend_from_slice(invite_id);
+ out.extend_from_slice(invite_server_secret_hash);
+ out.extend_from_slice(&expires_at.to_be_bytes());
+ out.extend_from_slice(&max_accepted.to_be_bytes());
+ Ok(out)
+}
+
+fn create_namespace_invite_intent(
+ request: &pb::BitBoxSyncSignCreateNamespaceInviteIntentRequest,
+ keys: &IdentityKeys,
+) -> Result<Vec<u8>, Error> {
+ let action_fields = create_namespace_invite_action_fields(
+ &request.namespace_id,
+ &request.invite_id,
+ &request.invite_server_secret_hash,
+ request.expires_at,
+ request.max_accepted,
+ )?;
+ sensitive_action_intent(
+ &request.challenge,
+ SENSITIVE_ACTION_CREATE_NAMESPACE_INVITE,
+ &action_fields,
+ keys,
+ )
+}
+
+fn validate_server_origin(server_origin: &str) -> Result<(), Error> {
+ if !server_origin.is_ascii()
+ || server_origin.len() > MAX_SERVER_ORIGIN_LEN
+ || server_origin
+ .as_bytes()
+ .iter()
+ .any(|byte| byte.is_ascii_control())
+ || !server_origin.starts_with("https://")
+ {
+ return Err(Error::InvalidInput);
+ }
+ let authority = &server_origin["https://".len()..];
+ if authority.is_empty()
+ || authority
+ .as_bytes()
+ .iter()
+ .any(|byte| matches!(*byte, b'/' | b'?' | b'#' | b'@' | b' '))
+ || authority
+ .as_bytes()
+ .iter()
+ .any(|byte| byte.is_ascii_uppercase())
+ {
+ return Err(Error::InvalidInput);
+ }
+
+ let host = if let Some((host, port)) = authority.rsplit_once(':') {
+ if host.contains(':') || port.is_empty() || port.len() > 1 && port.starts_with('0') {
+ return Err(Error::InvalidInput);
+ }
+ let port = port.parse::<u16>().map_err(|_| Error::InvalidInput)?;
+ if port == 0 || port == 443 {
+ return Err(Error::InvalidInput);
+ }
+ host
+ } else {
+ authority
+ };
+ if host.is_empty()
+ || host.starts_with('.')
+ || host.ends_with('.')
+ || host.split('.').any(str::is_empty)
+ || host.split('.').any(|label| {
+ label.starts_with('-')
+ || label.ends_with('-')
+ || !label
+ .as_bytes()
+ .iter()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
+ })
+ {
+ return Err(Error::InvalidInput);
+ }
+ Ok(())
+}
+
+fn join_request_payload(
+ request: &pb::BitBoxSyncSignJoinRequestIntentRequest,
+ keys: &IdentityKeys,
+) -> Result<Vec<u8>, Error> {
+ if request.namespace_id.len() != NAMESPACE_ID_LEN || request.invite_id.len() != INVITE_ID_LEN {
+ return Err(Error::InvalidInput);
+ }
+ validate_server_origin(&request.server_origin)?;
+
+ let server_origin_hash: [u8; SERVER_ORIGIN_HASH_LEN] =
+ Sha256::digest(request.server_origin.as_bytes()).into();
+ let key_id = key_id(&keys.auth_public_key);
+ let mut out = Vec::with_capacity(
+ JOIN_REQUEST_PREFIX.len()
+ + 1
+ + NAMESPACE_ID_LEN
+ + INVITE_ID_LEN
+ + SERVER_ORIGIN_HASH_LEN
+ + 1
+ + KEY_ID_LEN
+ + keys.auth_public_key.len()
+ + keys.wrap_public_key.len()
+ + 8,
+ );
+ out.extend_from_slice(JOIN_REQUEST_PREFIX);
+ out.push(JOIN_REQUEST_VERSION);
+ out.extend_from_slice(&request.namespace_id);
+ out.extend_from_slice(&request.invite_id);
+ out.extend_from_slice(&server_origin_hash);
+ out.push(KIND_CODE_KEYSTORE);
+ out.extend_from_slice(&key_id);
+ out.extend_from_slice(&keys.auth_public_key);
+ out.extend_from_slice(&keys.wrap_public_key);
+ out.extend_from_slice(&request.expires_at.to_be_bytes());
+ Ok(out)
+}
+
+fn format_fingerprint(bytes: &[u8]) -> String {
+ let encoded = hex::encode_upper(bytes);
+ format!("{} {}", &encoded[..4], &encoded[4..])
+}
+
+fn namespace_fingerprint(namespace_id: &[u8]) -> Result<String, Error> {
+ if namespace_id.len() != NAMESPACE_ID_LEN {
+ return Err(Error::InvalidInput);
+ }
+ let mut hasher = Sha256::new();
+ hasher.update(b"BitBoxSync namespace fingerprint v1");
+ hasher.update(namespace_id);
+ let hash: [u8; 32] = hasher.finalize().into();
+ Ok(format_fingerprint(&hash[..4]))
+}
+
+fn invite_fingerprint(namespace_id: &[u8], invite_id: &[u8]) -> Result<String, Error> {
+ if namespace_id.len() != NAMESPACE_ID_LEN || invite_id.len() != INVITE_ID_LEN {
+ return Err(Error::InvalidInput);
+ }
+ let mut hasher = Sha256::new();
+ hasher.update(b"BitBoxSync invite fingerprint v1");
+ hasher.update(namespace_id);
+ hasher.update(invite_id);
+ let hash: [u8; 32] = hasher.finalize().into();
+ Ok(format_fingerprint(&hash[..4]))
+}
+
+fn format_expiry(expires_at: u64) -> Result<String, Error> {
+ if expires_at > u32::MAX as u64 {
+ return Err(Error::InvalidInput);
+ }
+ let expiry = util::datetime::format_datetime(expires_at as u32, 0, false)
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(format!("{} UTC", expiry))
+}
+
+async fn confirm_create_namespace_invite(
+ hal: &mut impl crate::hal::Hal,
+ invite: &str,
+ namespace: &str,
+ expires_at: u64,
+ max_accepted: u32,
+) -> Result<(), Error> {
+ let expiry = format_expiry(expires_at)?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Create invite\nwith code\n{}", invite),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Namespace\n{}", namespace),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Expires\n{}", expiry),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Max members: {}", max_accepted),
+ longtouch: true,
+ ..Default::default()
+ })
+ .await?;
+ Ok(())
+}
+
+async fn confirm_join_request(
+ hal: &mut impl crate::hal::Hal,
+ invite: &str,
+ server_origin: &str,
+ namespace: &str,
+ expires_at: u64,
+) -> Result<(), Error> {
+ let expiry = format_expiry(expires_at)?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Join namespace\nwith code\n{}", invite),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Server: {}", server_origin),
+ scrollable: true,
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Namespace\n{}", namespace),
+ accept_is_nextarrow: true,
+ ..Default::default()
+ })
+ .await?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: &format!("Expires\n{}", expiry),
+ longtouch: true,
+ ..Default::default()
+ })
+ .await?;
+ Ok(())
+}
+
+fn sign_payload(keys: &IdentityKeys, payload: &[u8]) -> [u8; SIGNATURE_LEN] {
+ let expanded_secret_key = ed25519::expanded_secret_key_from_seed(&keys.auth_seed);
+ ed25519::sign_with_expanded_secret_key(&expanded_secret_key, payload).signature
+}
+
+fn signature_response(signature: [u8; SIGNATURE_LEN]) -> Response {
+ Response::BitboxSync(pb::BitBoxSyncResponse {
+ response: Some(pb::bit_box_sync_response::Response::Signature(
+ pb::BitBoxSyncSignatureResponse {
+ signature: signature.to_vec(),
+ },
+ )),
+ })
+}
+
+fn labeled_extract(
+ suite_id: &[u8],
+ salt: Option<&[u8]>,
+ label: &[u8],
+ ikm: &[u8],
+) -> Result<Zeroizing<[u8; 32]>, Error> {
+ let mut labeled_ikm = Zeroizing::new(Vec::with_capacity(
+ b"HPKE-v1".len() + suite_id.len() + label.len() + ikm.len(),
+ ));
+ labeled_ikm.extend_from_slice(b"HPKE-v1");
+ labeled_ikm.extend_from_slice(suite_id);
+ labeled_ikm.extend_from_slice(label);
+ labeled_ikm.extend_from_slice(ikm);
+ let (mut prk, _) = Hkdf::<Sha256>::extract(salt, &labeled_ikm);
+ let mut out = Zeroizing::new([0u8; 32]);
+ out.copy_from_slice(&prk);
+ prk.zeroize();
+ Ok(out)
+}
+
+fn labeled_expand(
+ suite_id: &[u8],
+ prk: &[u8],
+ label: &[u8],
+ info: &[u8],
+ out: &mut [u8],
+) -> Result<(), Error> {
+ let mut labeled_info =
+ Vec::with_capacity(2 + b"HPKE-v1".len() + suite_id.len() + label.len() + info.len());
+ labeled_info.extend_from_slice(&(out.len() as u16).to_be_bytes());
+ labeled_info.extend_from_slice(b"HPKE-v1");
+ labeled_info.extend_from_slice(suite_id);
+ labeled_info.extend_from_slice(label);
+ labeled_info.extend_from_slice(info);
+ Hkdf::<Sha256>::from_prk(prk)
+ .map_err(|_| Error::Generic)?
+ .expand(&labeled_info, out)
+ .map_err(|_| Error::Generic)
+}
+
+fn hpke_shared_secret(
+ recipient_secret_key: &[u8; 32],
+ recipient_public_key: &[u8; 32],
+ enc: &[u8],
+) -> Result<Zeroizing<[u8; 32]>, Error> {
+ if enc.len() != 32 {
+ return Err(Error::InvalidInput);
+ }
+ let mut enc_array = [0u8; 32];
+ enc_array.copy_from_slice(enc);
+
+ let recipient_secret = X25519StaticSecret::from(*recipient_secret_key);
+ let enc_public = X25519PublicKey::from(enc_array);
+ let dh = Zeroizing::new(recipient_secret.diffie_hellman(&enc_public).to_bytes());
+ if dh.iter().all(|byte| *byte == 0) {
+ return Err(Error::InvalidInput);
+ }
+
+ let eae_prk = labeled_extract(b"KEM\x00\x20", None, b"eae_prk", &dh[..])?;
+ let mut kem_context = Vec::with_capacity(64);
+ kem_context.extend_from_slice(enc);
+ kem_context.extend_from_slice(recipient_public_key);
+
+ let mut shared_secret = Zeroizing::new([0u8; 32]);
+ labeled_expand(
+ b"KEM\x00\x20",
+ &eae_prk[..],
+ b"shared_secret",
+ &kem_context,
+ &mut *shared_secret,
+ )?;
+ Ok(shared_secret)
+}
+
+fn hpke_open(
+ recipient_secret_key: &[u8; 32],
+ recipient_public_key: &[u8; 32],
+ enc: &[u8],
+ ciphertext: &[u8],
+) -> Result<Zeroizing<[u8; NAMESPACE_ID_LEN + NAMESPACE_DEK_LEN]>, Error> {
+ if ciphertext.len() != NAMESPACE_ID_LEN + NAMESPACE_DEK_LEN + 16 {
+ return Err(Error::InvalidInput);
+ }
+ let shared_secret = hpke_shared_secret(recipient_secret_key, recipient_public_key, enc)?;
+
+ let psk_id_hash = labeled_extract(b"HPKE\x00\x20\x00\x01\x00\x03", None, b"psk_id_hash", b"")?;
+ let info_hash = labeled_extract(
+ b"HPKE\x00\x20\x00\x01\x00\x03",
+ None,
+ b"info_hash",
+ WRAP_DEK_INFO,
+ )?;
+ let mut key_schedule_context = Vec::with_capacity(1 + 32 + 32);
+ key_schedule_context.push(0x00);
+ key_schedule_context.extend_from_slice(&psk_id_hash[..]);
+ key_schedule_context.extend_from_slice(&info_hash[..]);
+
+ let secret = labeled_extract(
+ b"HPKE\x00\x20\x00\x01\x00\x03",
+ Some(&shared_secret[..]),
+ b"secret",
+ b"",
+ )?;
+
+ let mut key = Zeroizing::new([0u8; 32]);
+ labeled_expand(
+ b"HPKE\x00\x20\x00\x01\x00\x03",
+ &secret[..],
+ b"key",
+ &key_schedule_context,
+ &mut *key,
+ )?;
+ let mut nonce = Zeroizing::new([0u8; 12]);
+ labeled_expand(
+ b"HPKE\x00\x20\x00\x01\x00\x03",
+ &secret[..],
+ b"base_nonce",
+ &key_schedule_context,
+ &mut *nonce,
+ )?;
+
+ let mut plaintext = Zeroizing::new([0u8; NAMESPACE_ID_LEN + NAMESPACE_DEK_LEN]);
+ let plaintext_len = plaintext.len();
+ plaintext.copy_from_slice(&ciphertext[..plaintext_len]);
+ let tag = &ciphertext[plaintext_len..];
+ chacha20poly1305::ChaCha20Poly1305::new((&*key).into())
+ .decrypt_in_place_detached((&*nonce).into(), b"", &mut *plaintext, tag.into())
+ .map_err(|_| Error::InvalidInput)?;
+ Ok(plaintext)
+}
+
+fn unwrap_namespace_dek(
+ keys: &IdentityKeys,
+ namespace_id: &[u8],
+ wrapped_dek: &[u8],
+) -> Result<Zeroizing<[u8; NAMESPACE_DEK_LEN]>, Error> {
+ if namespace_id.len() != NAMESPACE_ID_LEN
+ || wrapped_dek.len() != WRAPPED_DEK_LEN_V1
+ || wrapped_dek[0] != WRAPPED_DEK_VERSION
+ {
+ return Err(Error::InvalidInput);
+ }
+ let enc = &wrapped_dek[1..33];
+ let ciphertext = &wrapped_dek[33..];
+ let plaintext = hpke_open(
+ &keys.wrap_secret_key,
+ &keys.wrap_public_key,
+ enc,
+ ciphertext,
+ )?;
+ if &plaintext[..NAMESPACE_ID_LEN] != namespace_id {
+ return Err(Error::InvalidInput);
+ }
+
+ let mut namespace_dek = Zeroizing::new([0u8; NAMESPACE_DEK_LEN]);
+ namespace_dek.copy_from_slice(&plaintext[NAMESPACE_ID_LEN..]);
+ Ok(namespace_dek)
+}
+
+pub async fn process(
+ hal: &mut impl crate::hal::Hal,
+ request: &pb::BitBoxSyncRequest,
+) -> Result<Response, Error> {
+ let request = request.request.as_ref().ok_or(Error::InvalidInput)?;
+ let keys = identity_keys(hal).await?;
+ match request {
+ pb::bit_box_sync_request::Request::Identity(_) => {
+ Ok(Response::BitboxSync(pb::BitBoxSyncResponse {
+ response: Some(pb::bit_box_sync_response::Response::Identity(
+ pb::BitBoxSyncIdentityResponse {
+ auth_public_key: keys.auth_public_key.to_vec(),
+ wrap_public_key: keys.wrap_public_key.to_vec(),
+ },
+ )),
+ }))
+ }
+ pb::bit_box_sync_request::Request::SignLoginIntent(request) => {
+ let payload = login_intent(&request.challenge, &keys)?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: "Login",
+ longtouch: true,
+ ..Default::default()
+ })
+ .await?;
+ Ok(signature_response(sign_payload(&keys, &payload)))
+ }
+ pb::bit_box_sync_request::Request::SignRefreshIntent(request) => {
+ let payload = refresh_intent(&request.challenge, &keys)?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: "Refresh session",
+ longtouch: true,
+ ..Default::default()
+ })
+ .await?;
+ Ok(signature_response(sign_payload(&keys, &payload)))
+ }
+ pb::bit_box_sync_request::Request::SignRevokeAllTokensIntent(request) => {
+ let payload = revoke_all_tokens_intent(&request.challenge, &keys)?;
+ hal.ui()
+ .confirm(&ConfirmParams {
+ title: "BitBoxSync",
+ body: "Revoke all sessions",
+ longtouch: true,
+ ..Default::default()
+ })
+ .await?;
+ Ok(signature_response(sign_payload(&keys, &payload)))
+ }
+ pb::bit_box_sync_request::Request::SignCreateNamespaceInviteIntent(request) => {
+ let payload = create_namespace_invite_intent(request, &keys)?;
+ let namespace = namespace_fingerprint(&request.namespace_id)?;
+ let invite = invite_fingerprint(&request.namespace_id, &request.invite_id)?;
+ confirm_create_namespace_invite(
+ hal,
+ &invite,
+ &namespace,
+ request.expires_at,
+ request.max_accepted,
+ )
+ .await?;
+ Ok(signature_response(sign_payload(&keys, &payload)))
+ }
+ pb::bit_box_sync_request::Request::SignJoinRequestIntent(request) => {
+ let payload = join_request_payload(request, &keys)?;
+ let namespace = namespace_fingerprint(&request.namespace_id)?;
+ let invite = invite_fingerprint(&request.namespace_id, &request.invite_id)?;
+ confirm_join_request(
+ hal,
+ &invite,
+ &request.server_origin,
+ &namespace,
+ request.expires_at,
+ )
+ .await?;
+ Ok(signature_response(sign_payload(&keys, &payload)))
+ }
+ pb::bit_box_sync_request::Request::UnwrapNamespaceDek(request) => {
+ // Deliberately no confirmation prompt: an unlocked BitBox being present is sufficient
+ // to recover a missing namespace DEK without interrupting normal sync flows.
+ let namespace_dek =
+ unwrap_namespace_dek(&keys, &request.namespace_id, &request.wrapped_dek)?;
+ Ok(Response::BitboxSync(pb::BitBoxSyncResponse {
+ response: Some(pb::bit_box_sync_response::Response::UnwrapNamespaceDek(
+ pb::BitBoxSyncUnwrapNamespaceDekResponse {
+ namespace_dek: namespace_dek.to_vec(),
+ },
+ )),
+ }))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::hal::testing::TestingHal;
+ use crate::hal::testing::ui::Screen;
+ use crate::keystore::testing::mock_unlocked_using_mnemonic;
+ use hex_lit::hex;
+
+ const MNEMONIC: &str = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide";
+
+ async fn unlocked_hal() -> TestingHal<'static> {
+ mock_unlocked_using_mnemonic(MNEMONIC, "");
+ TestingHal::new()
+ }
+
+ fn fixed_identity_keys() -> IdentityKeys {
+ let auth_seed = [0x01u8; 32];
+ let auth_public_key = ed25519_dalek::SigningKey::from_bytes(&auth_seed)
+ .verifying_key()
+ .to_bytes();
+ let wrap_secret_key = Zeroizing::new([0x02u8; 32]);
+ let wrap_secret = X25519StaticSecret::from(*wrap_secret_key);
+ let wrap_public_key = X25519PublicKey::from(&wrap_secret).to_bytes();
+ IdentityKeys {
+ auth_seed: Zeroizing::new(auth_seed),
+ auth_public_key,
+ wrap_secret_key,
+ wrap_public_key,
+ }
+ }
+
+ #[test]
+ fn test_identity_seed_derivation_uses_hkdf_info_labels() {
+ let mut root = [0u8; 32];
+ for (idx, byte) in root.iter_mut().enumerate() {
+ *byte = idx as u8;
+ }
+
+ assert_eq!(
+ &derive_labeled_key(&root, AUTH_SEED_LABEL).unwrap()[..],
+ &hex!("916d5481be358ccd57c8cde0184005c6a29a135eb40c74d4a2718890c66dc437")
+ );
+ assert_eq!(
+ &derive_labeled_key(&root, WRAP_SEED_LABEL).unwrap()[..],
+ &hex!("f36acf48115db6a49209bf8f9592a3f42d3c23aeb1072c2f7fc813df78c412e0")
+ );
+ }
+
+ #[test]
+ fn test_canonical_payload_vectors() {
+ let keys = fixed_identity_keys();
+ assert_eq!(
+ login_intent(&[0x10u8; CHALLENGE_LEN], &keys).unwrap(),
+ hex!(
+ "626974626f7873796e632d696e74656e74010110101010101010101010101010101010101010101010101010101010101010100134750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5cce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59"
+ )
+ );
+ assert_eq!(
+ refresh_intent(&[0x11u8; CHALLENGE_LEN], &keys).unwrap(),
+ hex!(
+ "626974626f7873796e632d696e74656e74010211111111111111111111111111111111111111111111111111111111111111110134750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e"
+ )
+ );
+ assert_eq!(
+ revoke_all_tokens_intent(&[0x12u8; CHALLENGE_LEN], &keys).unwrap(),
+ hex!(
+ "626974626f7873796e632d696e74656e7401030112121212121212121212121212121212121212121212121212121212121212120134750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e"
+ )
+ );
+
+ let create_request = pb::BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ challenge: vec![0x13u8; CHALLENGE_LEN],
+ namespace_id: vec![0x20u8; NAMESPACE_ID_LEN],
+ invite_id: vec![0x21u8; INVITE_ID_LEN],
+ invite_server_secret_hash: vec![0x22u8; INVITE_SERVER_SECRET_HASH_LEN],
+ expires_at: 0x0102_0304_0506_0708,
+ max_accepted: 10,
+ };
+ assert_eq!(
+ create_namespace_invite_intent(&create_request, &keys).unwrap(),
+ hex!(
+ "626974626f7873796e632d696e74656e7401030213131313131313131313131313131313131313131313131313131313131313130134750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e2020202020202020202020202020202021212121212121212121212121212121222222222222222222222222222222222222222222222222222222222222222201020304050607080000000a"
+ )
+ );
+
+ let join_request = pb::BitBoxSyncSignJoinRequestIntentRequest {
+ namespace_id: vec![0x20u8; NAMESPACE_ID_LEN],
+ invite_id: vec![0x21u8; INVITE_ID_LEN],
+ server_origin: "https://sync.example".into(),
+ expires_at: 0x0102_0304_0506_0708,
+ };
+ assert_eq!(
+ join_request_payload(&join_request, &keys).unwrap(),
+ hex!(
+ "626974626f7873796e632d6a6f696e2d72657175657374012020202020202020202020202020202021212121212121212121212121212121ff93ec0a47d8af4a6dc161a681c24d41e1a2ddb9ea6d5c9dc55b106f4c1b6c150134750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5cce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d590102030405060708"
+ )
+ );
+ }
+
+ #[test]
+ fn test_hpke_shared_secret_rejects_low_order_enc() {
+ let recipient_secret_key = [7u8; 32];
+ let recipient_secret = X25519StaticSecret::from(recipient_secret_key);
+ let recipient_public_key = X25519PublicKey::from(&recipient_secret).to_bytes();
+
+ assert_eq!(
+ hpke_shared_secret(&recipient_secret_key, &recipient_public_key, &[0u8; 32]),
+ Err(Error::InvalidInput)
+ );
+ }
+
+ #[test]
+ fn test_unwrap_namespace_dek_vector() {
+ let keys = fixed_identity_keys();
+ let namespace_id = [0x20u8; NAMESPACE_ID_LEN];
+ let wrapped_dek = hex!(
+ "015dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef227e6fcdf85c2247224b9d8abf9de548438723773828c60f0e6e70428f1433253430bf700131cf4a5209108789961845a0c57925575dc6cd8d1cdf167d2e9de498"
+ );
+
+ assert_eq!(
+ &unwrap_namespace_dek(&keys, &namespace_id, &wrapped_dek).unwrap()[..],
+ &[0x24u8; NAMESPACE_DEK_LEN]
+ );
+
+ assert_eq!(
+ unwrap_namespace_dek(&keys, &[0x25u8; NAMESPACE_ID_LEN], &wrapped_dek),
+ Err(Error::InvalidInput)
+ );
+ let mut tampered = wrapped_dek;
+ tampered[WRAPPED_DEK_LEN_V1 - 1] ^= 0x01;
+ assert_eq!(
+ unwrap_namespace_dek(&keys, &namespace_id, &tampered),
+ Err(Error::InvalidInput)
+ );
+ }
+
+ #[test]
+ fn test_server_origin_validation_rejects_noncanonical_ports() {
+ assert!(validate_server_origin("https://sync.example").is_ok());
+ assert!(validate_server_origin("https://sync.example:8443").is_ok());
+ assert!(
+ validate_server_origin(&format!(
+ "https://{}",
+ "a".repeat(MAX_SERVER_ORIGIN_LEN - "https://".len())
+ ))
+ .is_ok()
+ );
+ assert_eq!(
+ validate_server_origin(&format!(
+ "https://{}",
+ "a".repeat(MAX_SERVER_ORIGIN_LEN - "https://".len() + 1)
+ )),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_server_origin("https://sync.example:443"),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_server_origin("https://sync.example:0443"),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_server_origin("https://sync.example:0001"),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ validate_server_origin("https://bad-.example"),
+ Err(Error::InvalidInput)
+ );
+ }
+
+ fn bitboxsync_response(response: Response) -> pb::bit_box_sync_response::Response {
+ match response {
+ Response::BitboxSync(response) => response.response.unwrap(),
+ _ => panic!("unexpected response"),
+ }
+ }
+
+ fn signature(response: Response) -> Vec<u8> {
+ match bitboxsync_response(response) {
+ pb::bit_box_sync_response::Response::Signature(response) => response.signature,
+ _ => panic!("unexpected response"),
+ }
+ }
+
+ fn identity(response: Response) -> pb::BitBoxSyncIdentityResponse {
+ match bitboxsync_response(response) {
+ pb::bit_box_sync_response::Response::Identity(response) => response,
+ _ => panic!("unexpected response"),
+ }
+ }
+
+ #[async_test::test]
+ async fn test_identity_is_deterministic() {
+ let mut hal = unlocked_hal().await;
+ let response = identity(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::Identity(
+ pb::BitBoxSyncIdentityRequest {},
+ )),
+ },
+ )
+ .await
+ .unwrap(),
+ );
+ assert_eq!(response.auth_public_key.len(), 32);
+ assert_eq!(response.wrap_public_key.len(), 32);
+
+ let mut hal = unlocked_hal().await;
+ let response_again = identity(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::Identity(
+ pb::BitBoxSyncIdentityRequest {},
+ )),
+ },
+ )
+ .await
+ .unwrap(),
+ );
+ assert_eq!(response, response_again);
+ assert!(hal.ui.screens.is_empty());
+ }
+
+ #[async_test::test]
+ async fn test_sign_login_confirms_and_signs_canonical_payload() {
+ let mut hal = unlocked_hal().await;
+ let keys = identity_keys(&mut hal).await.unwrap();
+ let challenge = [42u8; CHALLENGE_LEN];
+ let expected_payload = login_intent(&challenge, &keys).unwrap();
+
+ let sig = signature(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::SignLoginIntent(
+ pb::BitBoxSyncSignLoginIntentRequest {
+ challenge: challenge.to_vec(),
+ },
+ )),
+ },
+ )
+ .await
+ .unwrap(),
+ );
+ assert_eq!(
+ hal.ui.screens,
+ vec![Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Login".into(),
+ longtouch: true,
+ }]
+ );
+ let public_key = ed25519_dalek::VerifyingKey::from_bytes(&keys.auth_public_key).unwrap();
+ public_key
+ .verify_strict(
+ &expected_payload,
+ &ed25519_dalek::Signature::from_slice(&sig).unwrap(),
+ )
+ .unwrap();
+ }
+
+ #[async_test::test]
+ async fn test_signing_validates_lengths_before_confirming() {
+ let mut hal = unlocked_hal().await;
+ assert_eq!(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::SignLoginIntent(
+ pb::BitBoxSyncSignLoginIntentRequest {
+ challenge: vec![0u8; CHALLENGE_LEN - 1],
+ },
+ )),
+ },
+ )
+ .await,
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(
+ pb::bit_box_sync_request::Request::SignCreateNamespaceInviteIntent(
+ pb::BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ challenge: vec![0u8; CHALLENGE_LEN],
+ namespace_id: vec![0u8; NAMESPACE_ID_LEN - 1],
+ invite_id: vec![0u8; INVITE_ID_LEN],
+ invite_server_secret_hash: vec![0u8; INVITE_SERVER_SECRET_HASH_LEN],
+ expires_at: 123,
+ max_accepted: 1,
+ },
+ ),
+ ),
+ },
+ )
+ .await,
+ Err(Error::InvalidInput)
+ );
+ assert!(hal.ui.screens.is_empty());
+ }
+
+ #[async_test::test]
+ async fn test_create_namespace_invite_confirms_multiple_screens() {
+ let mut hal = unlocked_hal().await;
+ let keys = identity_keys(&mut hal).await.unwrap();
+ let request = pb::BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ challenge: vec![7u8; CHALLENGE_LEN],
+ namespace_id: vec![1u8; NAMESPACE_ID_LEN],
+ invite_id: vec![2u8; INVITE_ID_LEN],
+ invite_server_secret_hash: vec![3u8; INVITE_SERVER_SECRET_HASH_LEN],
+ expires_at: 123,
+ max_accepted: 5,
+ };
+ let expected_payload = create_namespace_invite_intent(&request, &keys).unwrap();
+
+ let sig = signature(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(
+ pb::bit_box_sync_request::Request::SignCreateNamespaceInviteIntent(request),
+ ),
+ },
+ )
+ .await
+ .unwrap(),
+ );
+ assert_eq!(
+ hal.ui.screens,
+ vec![
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Create invite\nwith code\n6242 1288".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Namespace\n3208 8CFF".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Expires\nThu 1970-01-01\n00:02 UTC".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Max members: 5".into(),
+ longtouch: true,
+ },
+ ]
+ );
+ let public_key = ed25519_dalek::VerifyingKey::from_bytes(&keys.auth_public_key).unwrap();
+ public_key
+ .verify_strict(
+ &expected_payload,
+ &ed25519_dalek::Signature::from_slice(&sig).unwrap(),
+ )
+ .unwrap();
+ }
+
+ #[async_test::test]
+ async fn test_join_request_confirms_multiple_screens() {
+ let mut hal = unlocked_hal().await;
+ let keys = identity_keys(&mut hal).await.unwrap();
+ let request = pb::BitBoxSyncSignJoinRequestIntentRequest {
+ namespace_id: vec![1u8; NAMESPACE_ID_LEN],
+ invite_id: vec![2u8; INVITE_ID_LEN],
+ server_origin: "https://sync.example".into(),
+ expires_at: 123,
+ };
+ let expected_payload = join_request_payload(&request, &keys).unwrap();
+
+ let sig = signature(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::SignJoinRequestIntent(
+ request,
+ )),
+ },
+ )
+ .await
+ .unwrap(),
+ );
+ assert_eq!(
+ hal.ui.screens,
+ vec![
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Join namespace\nwith code\n6242 1288".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Server: https://sync.example".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Namespace\n3208 8CFF".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "BitBoxSync".into(),
+ body: "Expires\nThu 1970-01-01\n00:02 UTC".into(),
+ longtouch: true,
+ },
+ ]
+ );
+ let public_key = ed25519_dalek::VerifyingKey::from_bytes(&keys.auth_public_key).unwrap();
+ public_key
+ .verify_strict(
+ &expected_payload,
+ &ed25519_dalek::Signature::from_slice(&sig).unwrap(),
+ )
+ .unwrap();
+ }
+
+ #[async_test::test]
+ async fn test_signing_rejects_expiry_that_cannot_be_displayed() {
+ let mut hal = unlocked_hal().await;
+ assert_eq!(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(
+ pb::bit_box_sync_request::Request::SignCreateNamespaceInviteIntent(
+ pb::BitBoxSyncSignCreateNamespaceInviteIntentRequest {
+ challenge: vec![0u8; CHALLENGE_LEN],
+ namespace_id: vec![0u8; NAMESPACE_ID_LEN],
+ invite_id: vec![0u8; INVITE_ID_LEN],
+ invite_server_secret_hash: vec![0u8; INVITE_SERVER_SECRET_HASH_LEN],
+ expires_at: u64::from(u32::MAX) + 1,
+ max_accepted: 1,
+ },
+ ),
+ ),
+ },
+ )
+ .await,
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ process(
+ &mut hal,
+ &pb::BitBoxSyncRequest {
+ request: Some(pb::bit_box_sync_request::Request::SignJoinRequestIntent(
+ pb::BitBoxSyncSignJoinRequestIntentRequest {
+ namespace_id: vec![0u8; NAMESPACE_ID_LEN],
+ invite_id: vec![0u8; INVITE_ID_LEN],
+ server_origin: "https://sync.example".into(),
+ expires_at: u64::from(u32::MAX) + 1,
+ },
+ )),
+ },
+ )
+ .await,
+ Err(Error::InvalidInput)
+ );
+ assert!(hal.ui.screens.is_empty());
+ }
+}
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index 15f7551..3d63094 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
-#[cfg(feature = "ed25519")]
pub mod ed25519;
use alloc::boxed::Box;
@@ -846,6 +845,24 @@ pub async fn bip85_ln(
Ok(entropy)
}
+/// Computes 32 byte deterministic root entropy for BitBoxSync identities.
+/// It is the same as BIP-85, but using app number 1112758606' (= 0x4253594e = 'BSYN').
+pub async fn bip85_bitboxsync(
+ hal: &mut impl crate::hal::Hal,
+) -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
+ let keypath = [
+ 83696968 + HARDENED,
+ 0x4253594e + HARDENED,
+ 0 + HARDENED,
+ 0 + HARDENED,
+ 0 + HARDENED,
+ ];
+
+ let mut entropy = bip85_entropy(hal, &keypath).await?;
+ entropy.truncate(32);
+ Ok(entropy)
+}
+
/// Sign message with private key using the given private key.
///
/// Sign a message using the private key at the keypath, which is optionally tweaked with the given
@@ -2089,6 +2106,25 @@ mod tests {
assert!(bip85_ln(&mut TestingHal::new(), HARDENED).await.is_err());
}
+ #[async_test::test]
+ async fn test_bip85_bitboxsync() {
+ lock();
+ assert!(bip85_bitboxsync(&mut TestingHal::new()).await.is_err());
+
+ mock_unlocked_using_mnemonic(
+ "virtual weapon code laptop defy cricket vicious target wave leopard garden give",
+ "",
+ );
+
+ assert_eq!(
+ bip85_bitboxsync(&mut TestingHal::new())
+ .await
+ .unwrap()
+ .as_slice(),
+ hex!("849f3df6b47006f4a6dd74c8fd078101bff22a124214472a4dd7859da04a5a17"),
+ );
+ }
+
#[async_test::test]
async fn test_fixtures() {
struct Test {
diff --git a/src/rust/bitbox02-rust/src/keystore/ed25519.rs b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
index 36d49f0..bb4239e 100644
--- a/src/rust/bitbox02-rust/src/keystore/ed25519.rs
+++ b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
@@ -6,6 +6,9 @@ use crate::hash::Sha512;
use bip32_ed25519::{ED25519_EXPANDED_SECRET_KEY_SIZE, Xprv, Xpub};
use bitcoin::hashes::{Hash, HashEngine, Hmac, HmacEngine, sha256, sha512};
+use digest::Digest;
+use ed25519_dalek::VerifyingKey;
+use ed25519_dalek::hazmat::{ExpandedSecretKey, raw_sign};
fn hmac_sha512(key: &[u8], msg: &[u8]) -> [u8; 64] {
let mut engine = HmacEngine::<sha512::Hash>::new(key);
@@ -74,7 +77,22 @@ pub async fn get_xpub_twice(
pub struct SignResult {
pub signature: [u8; 64],
- pub public_key: ed25519_dalek::VerifyingKey,
+ pub public_key: VerifyingKey,
+}
+
+/// Expands a 32-byte Ed25519 seed using the firmware's compact SHA-512 implementation.
+pub fn expanded_secret_key_from_seed(seed: &[u8; 32]) -> ExpandedSecretKey {
+ let hash = Sha512::digest(seed);
+ ExpandedSecretKey::from_bytes(hash.as_ref())
+}
+
+/// Signs `msg` with an expanded Ed25519 secret key using the firmware's compact SHA-512 implementation.
+pub fn sign_with_expanded_secret_key(secret_key: &ExpandedSecretKey, msg: &[u8]) -> SignResult {
+ let public_key = VerifyingKey::from(secret_key);
+ SignResult {
+ signature: raw_sign::<Sha512>(secret_key, msg, &public_key).to_bytes(),
+ public_key,
+ }
}
pub async fn sign(
@@ -83,14 +101,8 @@ pub async fn sign(
msg: &[u8; 32],
) -> Result<SignResult, ()> {
let xprv = get_xprv(hal, keypath).await?;
- let secret_key =
- ed25519_dalek::hazmat::ExpandedSecretKey::from_bytes(&xprv.expanded_secret_key());
- let public_key = ed25519_dalek::VerifyingKey::from(&secret_key);
- Ok(SignResult {
- signature: ed25519_dalek::hazmat::raw_sign::<Sha512>(&secret_key, msg, &public_key)
- .to_bytes(),
- public_key,
- })
+ let secret_key = ExpandedSecretKey::from_bytes(&xprv.expanded_secret_key());
+ Ok(sign_with_expanded_secret_key(&secret_key, msg))
}
#[cfg(test)]
diff --git a/test/simulator-graphical-bb03/Cargo.lock b/test/simulator-graphical-bb03/Cargo.lock
index 0bc1bc7..0ce5fd4 100644
--- a/test/simulator-graphical-bb03/Cargo.lock
+++ b/test/simulator-graphical-bb03/Cargo.lock
@@ -507,6 +507,7 @@ dependencies = [
"bitcoin",
"bitcoin_hashes",
"blake2",
+ "chacha20poly1305",
"crc",
"digest",
"ed25519-dalek",
@@ -514,6 +515,7 @@ dependencies = [
"futures-lite",
"hex",
"hex_lit",
+ "hkdf",
"hmac",
"keccak",
"minicbor",
@@ -526,6 +528,7 @@ dependencies = [
"sha3",
"streaming-silent-payments",
"util",
+ "x25519-dalek",
"zeroize",
]
@@ -1046,6 +1049,7 @@ dependencies = [
"fiat-crypto",
"rustc_version 0.4.1",
"subtle",
+ "zeroize",
]
[[package]]
@@ -1142,6 +1146,7 @@ dependencies = [
"sha2",
"signature",
"subtle",
+ "zeroize",
]
[[package]]
@@ -1565,6 +1570,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3927,6 +3941,7 @@ checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
+ "zeroize",
]
[[package]]
diff --git a/test/simulator-graphical/Cargo.lock b/test/simulator-graphical/Cargo.lock
index 4a09b79..36e70dc 100644
--- a/test/simulator-graphical/Cargo.lock
+++ b/test/simulator-graphical/Cargo.lock
@@ -452,6 +452,7 @@ dependencies = [
"bitcoin",
"bitcoin_hashes",
"blake2",
+ "chacha20poly1305",
"crc",
"digest",
"ed25519-dalek",
@@ -459,6 +460,7 @@ dependencies = [
"futures-lite",
"hex",
"hex_lit",
+ "hkdf",
"hmac",
"keccak",
"minicbor",
@@ -471,6 +473,7 @@ dependencies = [
"sha3",
"streaming-silent-payments",
"util",
+ "x25519-dalek",
"zeroize",
]
@@ -998,6 +1001,7 @@ dependencies = [
"fiat-crypto",
"rustc_version 0.4.0",
"subtle",
+ "zeroize",
]
[[package]]
@@ -1094,6 +1098,7 @@ dependencies = [
"sha2",
"signature",
"subtle",
+ "zeroize",
]
[[package]]
@@ -1502,6 +1507,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3990,6 +4004,7 @@ checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96"
dependencies = [
"curve25519-dalek",
"rand_core",
+ "zeroize",
]
[[package]]
diff --git a/versions.json b/versions.json
index 67dc2cc..8e94527 100644
--- a/versions.json
+++ b/versions.json
@@ -1,4 +1,4 @@
{
- "firmware": "v9.26.1",
+ "firmware": "v9.27.0",
"bootloader": "v1.1.2"
}
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.