From 868e686e918b4b3326c30c95578e30ffe1d4ae21 Mon Sep 17 00:00:00 2001 From: Coda Hale Date: Fri, 21 Oct 2022 09:36:04 -0600 Subject: [PATCH] feat: initial commit --- .cargo/config.toml | 2 + .github/dependabot.yml | 10 ++ .github/workflows/rust.yml | 32 ++++ .gitignore | 2 + Cargo.toml | 22 +++ LICENSE-APACHE | 202 ++++++++++++++++++++++ LICENSE-MIT | 25 +++ README.md | 91 ++++++++++ design.md | 332 ++++++++++++++++++++++++++++++++++++ rustfmt.toml | 2 + src/lib.rs | 337 +++++++++++++++++++++++++++++++++++++ tests/transcripts.rs | 152 +++++++++++++++++ xtask/Cargo.toml | 13 ++ xtask/src/xtask.rs | 43 +++++ 14 files changed, 1265 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/rust.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 design.md create mode 100644 rustfmt.toml create mode 100644 src/lib.rs create mode 100644 tests/transcripts.rs create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/xtask.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..13b4ef51 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..ed11bccd --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,32 @@ +name: Rust CI + +on: + push: + branches: + - "main" + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + rust: + - stable + - beta + steps: + - uses: actions/checkout@v3 + name: "Checkout source" + - uses: actions-rs/toolchain@v1.0.7 + name: "Install Rust" + with: + profile: default + toolchain: ${{ matrix.rust }} + override: true + - uses: Swatinem/rust-cache@v2 + name: "Cache dependencies" + - uses: actions-rs/cargo@v1.0.3 + name: "Build, test, and check" + with: + command: xtask + args: ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4fffb2f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..3257ec06 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lockstitch" +version = "0.1.0" +edition = "2021" + +[dependencies] +blake3 = { version = "1.3.1", default-features = false } +c2-chacha = { version = "0.3.3", features = ["std", "simd"], default-features = false } +constant_time_eq = "0.2.4" +rand_core = { version = "0.6.4", default-features = false, optional = true } + +[features] +default = ["std", "hedge"] +std = [] +hedge = ["rand_core"] + +[workspace] +members = ["xtask"] + +[dev-dependencies] +criterion = "0.4.0" +proptest = "1.0.0" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + 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. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..8fe9686e --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2021 Coda Hale + +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/README.md b/README.md new file mode 100644 index 00000000..350be8cc --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Lockstitch + +Lockstitch is an incremental, stateful cryptographic primitive for symmetric-key cryptographic +operations (e.g. hashing, encryption, message authentication codes, and authenticated encryption) +in complex protocols. Inspired by TupleHash, STROBE, Noise Protocol's stateful objects, and +Xoodyak's Cyclist mode, Lockstitch combines BLAKE3 and ChaCha8 to provide GiB/sec performance on +modern processors at a 128-bit security level. + +## Use + +Lockstitch is used to compose cryptographic protocols. + +For example, we can create message digests: + +```rust +fn digest(data: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + let mut md = lockstitch::Protocol::new("com.example.md"); + md.mix(data); + md.derive(&mut out); + out +} + +assert_eq!(digest(b"this is a message"), digest(b"this is a message")); +assert_ne!(digest(b"this is a message"), digest(b"this is another message")); +``` + +We can create message authentication codes: + +```rust +fn mac(key: &[u8], data: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + let mut mac = lockstitch::Protocol::new("com.example.mac"); + mac.mix(key); + mac.mix(data); + mac.derive(&mut out); + out +} + +assert_eq!(mac(b"a key", b"a message"), mac(b"a key", b"a message")); +``` + +We can even create authenticated encryption: + +```rust +fn aead_encrypt(key: &[u8], nonce: &[u8], ad: &[u8], plaintext: &[u8]) -> Vec { + let mut out = vec![0u8; plaintext.len() + lockstitch::TAG_LEN]; + let (ciphertext, tag) = out.split_at_mut(plaintext.len()); + ciphertext.copy_from_slice(plaintext); + + let mut aead = lockstitch::Protocol::new("com.example.aead"); + aead.mix(key); + aead.mix(nonce); + aead.mix(ad); + aead.encrypt(ciphertext); + aead.tag(tag); + + out +} + +fn aead_decrypt(key: &[u8], nonce: &[u8], ad: &[u8], ciphertext: &[u8]) -> Option> { + let (ciphertext, tag) = ciphertext.split_at(ciphertext.len() - lockstitch::TAG_LEN); + let mut plaintext = ciphertext.to_vec(); + + let mut aead = lockstitch::Protocol::new("com.example.aead"); + aead.mix(key); + aead.mix(nonce); + aead.mix(ad); + aead.decrypt(&mut plaintext); + aead.check_tag(tag).then_some(plaintext) +} + +let plaintext = b"a message".to_vec(); +let ciphertext = aead_encrypt(b"a key", b"a nonce", b"some data", &plaintext); +assert_eq!(aead_decrypt(b"a key", b"a nonce", b"some data", &ciphertext), Some(plaintext)); +assert_eq!(aead_decrypt(b"another key", b"a nonce", b"some data", &ciphertext), None); +assert_eq!(aead_decrypt(b"a key", b"another nonce", b"some data", &ciphertext), None); +assert_eq!(aead_decrypt(b"a key", b"a nonce", b"some other data", &ciphertext), None); + +let mut bad_ciphertext = ciphertext.to_vec(); +bad_ciphertext[5] ^= 1; // flip one bit +assert_eq!(aead_decrypt(b"a key", b"a nonce", b"some data", &bad_ciphertext), None); +``` + +For more information, see [`design.md`](design.md). + +## License + +Copyright © 2022 Coda Hale + +Distributed under the Apache License 2.0 or MIT License. diff --git a/design.md b/design.md new file mode 100644 index 00000000..ef3015be --- /dev/null +++ b/design.md @@ -0,0 +1,332 @@ +# Lockstitch Design Documentation + +## Preliminaries + +### Initializing A Protocol + +The basic unit of Lockstitch is the protocol, which is essentially a BLAKE3 hash. Every protocol is +initialized with a domain separation string, which is used to initialize a BLAKE3 hash in key +derivation function (KDF) mode: + +```text +function Initialize(domain): + state ← BLAKE3::KDF(domain) + return state +``` + +### Encoding An Operation + +Given this state, Lockstitch defines an unambiguous encoding for operations (similar to TupleHash). +Each operation begins by updating the protocol's state with the operation's unique 1-byte code: + +```text +state ← BLAKE3::Update(state, [operation]) +``` + +Having begun, an operation may update the protocol's state with operation-specific data. + +Once an operation is complete, the protocol's state is updated with the number of bytes processed in +the operation encoded as a 64-bit little-endian integer: + +```text +state ← BLAKE3::Update(state, LE64(count)) +``` + +This allows for the unambiguous encoding of multiple inputs and different types of operations as +well as operations which produce outputs which do not directly update the protocol's state. + +### Generating Output + +To generate any output during an operation, the protocol produces two 32-byte keys from the first 64 +bytes of XOF output from its BLAKE3 hash. The protocol then replaces its current state with a +BLAKE3 keyed hash created with the first key. Finally, a ChaCha8 stream is initialized with the +second key (with a counter of zero) and used to produce output. + +```text +K_0||K_1 ← BLAKE3::XOF(state, 64) +state ← BLAKE3::Keyed(K_0) +chacha ← ChaCha8::New(K_1) +``` + +While BLAKE3 can produce outputs of arbitrary length, Lockstitch uses ChaCha8 exclusively to +generate output values. This is done primarily to provide a clean separation of responsibilities in +the design. BLAKE3 effectively functions as a chained KDF, a task for which it was designed and for +which its fitness can be clearly analyzed. ChaCha8 functions as a pseudo-random function (PRF), a +task for which is was designed as well. Finally, despite the strong structural similarities between +ChaCha and BLAKE3's XOF, the use of ChaCha8 provides a performance benefit due to the reduced number +of rounds in the compression function. + +## Primitive Operations + +Lockstitch supports four primitive operations: `Mix`, `Derive`, `Encrypt`/`Decrypt`, and +`Tag`/`CheckTag`. + +### `Mix` + +`Mix` takes a byte sequence of arbitrary length and makes the protocol's state dependent on it: + +```text +function Mix(state, data): + state ← BLAKE3::Update(state, [0x01]) // Begin the operation. + state ← BLAKE3::Update(state, data) // Update the protocol's state with the data. + state ← BLAKE3::Update(state, LE64(|data|)) // End the operation with the length. + return state +``` + +Unlike a standard hash function, `Mix` operations (as with all other operations) are not +commutative. That is, `Mix("alpha"); Mix("bet")` is not equivalent to `Mix("alphabet")`. + +### `Derive` + +`Derive` produces a pseudo-random byte sequence of arbitrary length: + +```text +function Derive(state, n): + state ← BLAKE3::Update(state, [0x02]) // Begin the operation. + (K_0, K_1) ← BLAKE3::Finalize(state, 64) // Finalize the state into two keys. + state ← BLAKE3::Keyed(K_0) // Replace the protocol's state with a new keyed hash. + chacha ← ChaCha8::New(K_1) // Initialize a ChaCha8 instance. + out ← ChaCha8::Output(chacha, n) // Produce n bytes of ChaCha8 output. + state ← BLAKE3::Update(state, LE64(n)) // End the operation with the length. + return (state, out) +``` + +### `Encrypt`/`Decrypt` + +`Encrypt` uses ChaCha8 to encrypt a given plaintext with a key derived from the protocol's current +state and updates the protocol's state with the plaintext itself. + +```text +function Encrypt(state, plaintext): + state ← BLAKE3::Update(state, [0x03]) // Begin the operation. + (K_0, K_1) ← BLAKE3::Finalize(state, 64) // Finalize the state into two keys. + state ← BLAKE3::Keyed(K_0) // Replace the protocol's state with a new keyed hash. + chacha ← ChaCha8::New(K_1) // Initialize a ChaCha8 instance. + state ← BLAKE3::Update(state, plaintext) // Update the protocol's state with the plaintext. + out ← ChaCha8::Output(chacha, |plaintext|) // Produce a ChaCha8 keystream. + ciphertext ← plaintext ^ out // Encrypt the plaintext with ChaCha8 via XOR. + state ← BLAKE3::Update(state, LE64(|plaintext|)) // End the operation with the length. + return (state, ciphertext) +``` + +`Decrypt` is used to decrypt the outputs of `Encrypt`. + +```text +function Decrypt(state, ciphertext): + state ← BLAKE3::Update(state, [0x03]) // Begin the operation. + (K_0, K_1) ← BLAKE3::Finalize(state, 64) // Finalize the state into two keys. + state ← BLAKE3::Keyed(K_0) // Replace the protocol's state with a new keyed hash. + chacha ← ChaCha8::New(K_1) // Initialize a ChaCha8 instance. + out ← ChaCha8::Output(chacha, |ciphertext|) // Produce a ChaCha8 keystream. + plaintext ← ciphertext ^ out // Decrypt the ciphertext with ChaCha8 via XOR. + state ← BLAKE3::Update(state, plaintext) // Update the protocol's state with the plaintext. + state ← BLAKE3::Update(state, LE64(|ciphertext|)) // End the operation with the length. + return (state, plaintext) +``` + +Three points bear mentioning about `Encrypt` and `DECRYPT`. + +First, they provide no authentication by themselves. An attacker can modify a ciphertext and the +`Decrypt` operation will return a plaintext which was never encrypted. (That is, they are IND-CPA +secure but not IND-CCA secure.) + +Second, both `Encrypt` and `Decrypt` use the same `Crypt` operation code to ensure protocols have +the same state after both encrypting and decrypting data. The only difference between the two +operations is the order of operations. `Encrypt` updates the state before XORing with the keystream; +`Decrypt` updates the state afterwards. + +Finally, `Crypt` operations update the protocol's state with the plaintext, not with the ciphertext. +See the discussion on [Authenticated Encryption And Data +(AEAD)](#authenticated-encryption-and-data-aead) and [Signcryption](#signcryption) for why this is +important. + +### `Tag`/`CheckTag` + +The `Tag` operation produces a 16-byte authentication tag from ChaCha8 output: + +```text +function Tag(state): + state ← BLAKE3::Update(state, [0x05]) // Begin the operation. + (K_0, K_1) ← BLAKE3::Finalize(state, 64) // Finalize the state into two keys. + state ← BLAKE3::Keyed(K_0) // Replace the protocol's state with a new keyed hash. + chacha ← ChaCha8::New(K_1) // Initialize a ChaCha8 instance. + tag ← ChaCha8::Output(chacha, 16) // Produce a ChaCha8 keystream. + state ← BLAKE3::Update(state, LE64(16)) // End the operation with the length. + return (state, tag) +``` + +The `CheckTag` operation compares a received tag with a counterfactual tag produced by the `Tag` +operation: + +```text +function CheckTag(state, tag): + (state, tag') ← Tag(state) + return (state, tag == tag') +``` + +Authentication tags are compared using a constant time algorithm to prevent timing attacks. + +## Compound Operations + +By combining operations, we can use Lockstitch to construct a wide variety of cryptographic schemes. + +### Message Digests + +```text +function MessageDigest(data): + state ← Initialize("com.example.md") + state ← Mix(state, data) + (state, digest) ← Derive(state, 32) + return digest +``` + +### Message Authentication Codes + +```text +function Mac(key, data): + state ← Initialize("com.example.mac") + state ← Mix(state, key) + state ← Mix(state, data) + (state, tag) ← Tag(state) + return tag +``` + +### Authenticated Encryption And Data (AEAD) + +```text +function Seal(key, nonce, ad, plaintext): + state ← Initialize("com.example.aead") + state ← Mix(state, key) + state ← Mix(state, nonce) + state ← Mix(state, ad) + (state, ciphertext) ← Encrypt(state, plaintext) + (state, tag) ← Tag(state) + return (ciphertext, tag) +``` + +```text +function Open(key, nonce, ad, ciphertext, tag): + state ← Initialize("com.example.aead") + state ← Mix(state, key) + state ← Mix(state, nonce) + state ← Mix(state, ad) + (state, plaintext) ← Decrypt(state, ciphertext) + (state, tag_ok) ← CheckTag(state, tag) + if tag_ok: + return ⊥ + else: + return plaintext +``` + +This is effectively an Encrypt-And-Authenticate construction (as opposed to +Authenticate-Then-Encrypt or Encrypt-Then-Authenticate), which is IND-CCA secure with two caveats. + +First, if the authentication tag reveals anything about the plaintext, the result will be UF-CMA +secure (i.e. an attacker cannot forge new valid ciphertexts) but not EAV secure (e.g. a MAC +algorithm which includes part of the message in the tag would allow a passive eavesdropper to read +plaintext). Lockstitch's `Tag` operation leaks no information about its inputs if BLAKE3 is +collision resistant. + +Second, if the authentication tag is deterministic, the result will not be IND-CPA secure because +attacker can identity when the same message is sent twice by examining the tags. Because the ChaCha8 +key of the `Tag` operation is derived from the same BLAKE3 hash chain which produced the key for the +`Encrypt` operation, the authentication tag will only be deterministic if the encryption keystream +is deterministic. The use of a nonce in this construction ensures both encryption and authentication +are probabilistic. + +While Encrypt-Then-Authentication is the less contentious choice, Encrypt-And-Authenticate has +significant benefits with more complex constructions like [signcryption](#signcryption). + +## Complex Protocols + +Given an elliptic curve group like Ristretto255, Lockstitch can be used to build complex protocols +with asymmetric encryption. + +### Hybrid Public-Key Encryption + +```text +function HPKE_Encrypt(receiver.pub, plaintext): + ephemeral ← Ristretto255::KeyGen() + state ← Initialize("com.example.hpke") + state ← Mix(state, receiver.pub) + state ← Mix(state, ephemeral.pub) + state ← Mix(state, ECDH(receiver.pub, ephemeral.priv)) + (state, ciphertext) ← Encrypt(state, plaintext) + (state, tag) ← Tag(state) + return (ephemeral.pub, ciphertext, tag) +``` + +```text +function HPKE_Decrypt(receiver, ephemeral.pub, ciphertext, tag): + state ← Initialize("com.example.hpke") + state ← Mix(state, receiver.pub) + state ← Mix(state, ephemeral.pub) + state ← Mix(state, ECDH(ephemeral.pub, receiver.priv)) + (state, plaintext) ← Decrypt(state, ciphertext) + (state, tag) ← Tag(state) + (state, tag_ok) ← CheckTag(state, tag) + if tag_ok: + return ⊥ + else: + return plaintext +``` + +### Fiat-Shamir Transforms + +```text +function Sign(signer, message): + state ← Initialize("com.example.eddsa") + state ← Mix(state, signer.pub) + state ← Mix(state, message) + (k, I) ← Ristretto255::KeyGen() + state ← Mix(state, I) + (state, r) ← Ristretto255::Scalar(Derive(state, 64)) + s ← signer.priv * r + k + return (I, s) +``` + +```text +function Verify(signer.pub, message, I, s): + state ← Initialize("com.example.eddsa") + state ← Mix(state, signer.pub) + state ← Mix(state, message) + state ← Mix(state, I) + (state, r') ← Ristretto255::Scalar(Derive(state, 64)) + I' ← [s]G - [r']signer.pub + return I = I' +``` + +### Signcryption + +```text +function Signcrypt(sender, receiver.pub, plaintext): + ephemeral ← Ristretto255::KeyGen() + state ← Initialize("com.example.signcrypt") + state ← Mix(state, receiver.pub) + state ← Mix(state, sender.pub) + state ← Mix(state, ephemeral.pub) + state ← Mix(state, ECDH(receiver.pub, ephemeral.priv)) + (state, ciphertext) ← Encrypt(state, plaintext) + (k, I) ← Ristretto255::KeyGen() + state ← Mix(state, I) + (state, r) ← Ristretto255::Scalar(Derive(state, 64)) + s ← sender.priv * r + k + return (I, s) + return (ephemeral.pub, ciphertext, I, s) +``` + +```text +function Unsigncrypt(receiver, sender.pub, ephemeral.pub, I, s): + state ← Initialize("com.example.signcrypt") + state ← Mix(state, receiver.pub) + state ← Mix(state, sender.pub) + state ← Mix(state, ephemeral.pub) + state ← Mix(state, ECDH(ephemeral.pub, receiver.priv)) + (state, plaintext) ← Decrypt(state, ciphertext) + state ← Mix(state, I) + (state, r') ← Ristretto255::Scalar(Derive(state, 64)) + I' ← [s]G - [r']sender.pub + if I ≠ I': + return ⊥ + return plaintext +``` diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..9d974ada --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +use_small_heuristics = "Max" +newline_style = "Unix" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..bc25b734 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,337 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(not(feature = "std"), no_std)] + +use blake3::Hasher; +use c2_chacha::guts::ChaCha; +use constant_time_eq::constant_time_eq; + +#[cfg(feature = "hedge")] +use rand_core::{CryptoRng, RngCore}; + +/// The length of an authentication tag in bytes. +pub const TAG_LEN: usize = 16; + +/// A stateful object providing fine-grained symmetric-key cryptographic services like hashing, +/// message authentication codes, pseudo-random functions, authenticated encryption, and more. +#[derive(Debug, Clone)] +pub struct Protocol { + state: Hasher, +} + +impl Protocol { + /// Create a new protocol with the given domain. + #[inline(always)] + pub fn new(domain: &str) -> Protocol { + // Begin with BLAKE3 in KDF mode. + Protocol { state: Hasher::new_derive_key(domain) } + } + + /// Mixes the given slice into the protocol state. + #[inline(always)] + pub fn mix(&mut self, data: &[u8]) { + // Update the state with the operation code. + self.state.update(&[Operation::Mix as u8]); + + // Update the state with the given slice. + self.state.update(data); + + // Update the state with the length of the given slice as a 64-bit little-endian integer. + self.state.update(&(data.len() as u64).to_le_bytes()); + } + + /// Mixes the contents of the reader into the protocol state. + #[cfg(feature = "std")] + pub fn mix_stream(&mut self, reader: impl std::io::Read) -> std::io::Result { + self.copy_stream(reader, std::io::sink()) + } + + /// Mixes the contents of the reader into the protocol state while copying them to the writer. + #[cfg(feature = "std")] + pub fn copy_stream( + &mut self, + mut reader: impl std::io::Read, + mut writer: impl std::io::Write, + ) -> std::io::Result { + // Update the state with the operation code. + self.state.update(&[Operation::Mix as u8]); + + // 64KiB is a large enough buffer to enable all possible optimizations. + let mut buf = [0u8; 1024 * 64]; + let mut n = 0; + + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(x) => { + self.state.update(&buf[..x]); + writer.write_all(&buf[..x])?; + n += u64::try_from(x).expect("unexpected overflow"); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + // Update the state with the byte count as a 64-bit little-endian integer. + self.state.update(&n.to_le_bytes()); + + Ok(n) + } + + /// Derive output from the protocol's current state and fill the given slice with it. + #[inline(always)] + pub fn derive(&mut self, out: &mut [u8]) { + // Update the state with the operation code. + self.state.update(&[Operation::Derive as u8]); + + // Rekey the state. + let mut chacha = self.chain(); + + // Fill the output buffer with ChaCha8 output, keeping track of the length. Derive + // operations are usually short, so we use the narrow buffer size to reduce latency at + // the expense of throughput. + let mut tmp = [0u8; 64]; + let mut n = 0; + for chunk in out.chunks_mut(tmp.len()) { + chacha.refill(CHACHA_DROUNDS, &mut tmp); + chunk.copy_from_slice(&tmp[..chunk.len()]); + n += chunk.len(); + } + + // Update the state with the derived byte count as a 64-bit little-endian integer. + self.state.update(&(n as u64).to_le_bytes()); + } + + /// Derive output from the protocol's current state and return it as an array. + #[inline(always)] + pub fn derive_array(&mut self) -> [u8; N] { + let mut out = [0u8; N]; + self.derive(&mut out); + out + } + + /// Encrypt the given slice in place. + #[inline(always)] + pub fn encrypt(&mut self, in_out: &mut [u8]) { + // Update the state with the operation code. + self.state.update(&[Operation::Crypt as u8]); + + // Rekey the state. + let mut chacha = self.chain(); + + // Here we use the wide (4x) buffer size to enable throughput optimizations. + let mut tmp = [0u8; 64 * 4]; + let mut n = 0; + for plaintext in in_out.chunks_mut(tmp.len()) { + // Update the state with the plaintext. + self.state.update(plaintext); + + // XOR the plaintext with ChaCha8 output to produce ciphertext. + chacha.refill4(CHACHA_DROUNDS, &mut tmp); + for (p, k) in plaintext.iter_mut().zip(tmp.iter()) { + *p ^= *k; + } + + n += plaintext.len(); + } + + // Update the state with the encrypted byte count as a 64-bit little-endian integer. + self.state.update(&(n as u64).to_le_bytes()); + } + + /// Decrypt the given slice in place. + #[inline(always)] + pub fn decrypt(&mut self, in_out: &mut [u8]) { + // Update the state with the operation code. + self.state.update(&[Operation::Crypt as u8]); + + // Rekey the state. + let mut chacha = self.chain(); + + let mut tmp = [0u8; 64 * 4]; + let mut n = 0; + for ciphertext in in_out.chunks_mut(tmp.len()) { + // Generate a block of ChaCha8 output. + chacha.refill4(CHACHA_DROUNDS, &mut tmp); + + // XOR the ciphertext with ChaCha8 output to produce plaintext. + for (c, k) in ciphertext.iter_mut().zip(tmp.iter()) { + *c ^= *k; + } + + // Update the state with the plaintext. + self.state.update(ciphertext); + + n += ciphertext.len(); + } + + // Update the state with the decrypted byte count as a 64-bit little-endian integer. + self.state.update(&(n as u64).to_le_bytes()); + } + + /// Extract output from the protocol's current state and fill the given slice with it. + #[inline(always)] + pub fn tag(&mut self, out: &mut [u8]) { + // Update the state with the operation code. + self.state.update(&[Operation::Tag as u8]); + + // Rekey the state. + let mut chacha = self.chain(); + + // Truncate the first block of ChaCha8 output and use it as the tag. + let mut tmp = [0u8; 64]; + chacha.refill(CHACHA_DROUNDS, &mut tmp); + out.copy_from_slice(&tmp[..TAG_LEN]); + + // Update the state with the tag length as a 64-bit little-endian integer. + self.state.update(&(TAG_LEN as u64).to_le_bytes()); + } + + #[inline(always)] + #[must_use] + pub fn check_tag(&mut self, tag: &[u8]) -> bool { + if tag.len() != TAG_LEN { + return false; + } + + let mut tag_p = [0u8; TAG_LEN]; + self.tag(&mut tag_p); + constant_time_eq(tag, &tag_p) + } + + /// Seals the given mutable slice in place. + /// + /// The last `TAG_LEN` bytes of the slice will be overwritten with the authentication tag. + #[inline(always)] + pub fn seal(&mut self, in_out: &mut [u8]) { + // Split the buffer into plaintext and tag. + let (plaintext, tag) = in_out.split_at_mut(in_out.len() - TAG_LEN); + + // Encrypt the plaintext. + self.encrypt(plaintext); + + // Extract a tag. + self.tag(tag); + } + + /// Opens the given mutable slice in place. Returns the plaintext slice of `in_out` if the input + /// was authenticated. The last `TAG_LEN` bytes of the slice will be unmodified. + #[inline(always)] + #[must_use] + pub fn open<'a>(&mut self, in_out: &'a mut [u8]) -> Option<&'a [u8]> { + // Split the buffer into ciphertext and tag. + let (ciphertext, tag) = in_out.split_at_mut(in_out.len() - TAG_LEN); + + // Decrypt the ciphertext. + self.decrypt(ciphertext); + + // Check the tag. + if self.check_tag(tag) { + Some(ciphertext) + } else { + // Otherwise, the ciphertext is inauthentic and we zero out the inauthentic plaintext to + // avoid bugs where the caller forgets to check the return value of this function and + // discloses inauthentic plaintext. + ciphertext.fill(0); + None + } + } + + /// Clone the protocol and update it with the given secrets and 64 random bytes. Pass the clone + /// to the given function and return the result of that function. + #[cfg(feature = "hedge")] + #[must_use] + pub fn hedge( + &self, + mut rng: impl RngCore + CryptoRng, + secrets: &[impl AsRef<[u8]>], + f: impl Fn(&mut Self) -> Option, + ) -> R { + loop { + // Clone the protocol's state. + let mut clone = self.clone(); + + // Update the clone with the secrets. + for s in secrets { + clone.mix(s.as_ref()); + } + + // Update the clone with a random value. + let mut r = [0u8; 64]; + rng.fill_bytes(&mut r); + clone.mix(&r); + + // Call the given function with the clone and return if the function was successful. + if let Some(r) = f(&mut clone) { + return r; + } + } + } + + /// Replace the protocol's state with derived output and return a ChaCha instance. + #[inline(always)] + fn chain(&mut self) -> ChaCha { + // Generate 64 bytes of XOF output from the current state. + let mut tmp = [0u8; 64]; + self.state.finalize_xof().fill(&mut tmp); + + // Split the XOF output into two parts. + let (a, b) = tmp.split_at(32); + + // Use the first 32 bytes as the key for a new keyed BLAKE3 hasher. + self.state = Hasher::new_keyed(&a.try_into().expect("invalid key")); + + // Use the second 32 bytes as the key for ChaCha output using an all-zero nonce. + ChaCha::new(b.try_into().expect("invalid key"), &[0u8; 8]) + } +} + +#[derive(Debug, Clone, Copy)] +enum Operation { + Mix = 0x01, + Derive = 0x02, + Crypt = 0x03, + Tag = 0x04, +} + +const CHACHA_DROUNDS: u32 = 4; // aka ChaCha8 + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_ops() { + let mut sho = Protocol::new("this is a test"); + sho.mix(b"one"); + sho.mix(b"two"); + + let mut one = [0u8; 10]; + sho.derive(&mut one); + + sho.mix(b"three"); + + let mut two = [0u8; 10]; + sho.derive(&mut two); + + dbg!(one, two); + } + + #[test] + fn encrypt_decrypt() { + let mut message = b"this is a message".to_vec(); + + { + let mut a = Protocol::new("this is a test"); + a.mix(b"this is a key"); + a.encrypt(&mut message); + } + { + let mut a = Protocol::new("this is a test"); + a.mix(b"this is a key"); + a.decrypt(&mut message); + } + + assert_eq!(b"this is a message", message.as_slice()) + } +} diff --git a/tests/transcripts.rs b/tests/transcripts.rs new file mode 100644 index 00000000..6b25c6d7 --- /dev/null +++ b/tests/transcripts.rs @@ -0,0 +1,152 @@ +use lockstitch::{Protocol, TAG_LEN}; +use proptest::collection::vec; +use proptest::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +enum Input { + Mix(Vec), + Derive(usize), + Encrypt(Vec), + Decrypt(Vec), + Tag, +} + +#[derive(Clone, Debug, PartialEq)] +enum Output { + Derived(Vec), + Encrypted(Vec), + Decrypted(Vec), + Tagged(Vec), +} + +#[derive(Clone, Debug, PartialEq)] +struct Transcript { + domain: String, + inputs: Vec, +} + +fn apply_transcript(t: &Transcript) -> Vec { + let mut protocol = Protocol::new(&t.domain); + t.inputs + .iter() + .flat_map(|op| match op { + Input::Mix(data) => { + protocol.mix(data); + None + } + Input::Derive(n) => { + let mut out = vec![0u8; *n]; + protocol.derive(&mut out); + Some(Output::Derived(out)) + } + Input::Encrypt(plaintext) => { + let mut ciphertext = plaintext.clone(); + protocol.encrypt(&mut ciphertext); + Some(Output::Encrypted(ciphertext)) + } + Input::Decrypt(ciphertext) => { + let mut plaintext = ciphertext.clone(); + protocol.decrypt(&mut plaintext); + Some(Output::Decrypted(plaintext)) + } + Input::Tag => { + let mut tag = vec![0u8; TAG_LEN]; + protocol.tag(&mut tag); + Some(Output::Tagged(tag)) + } + }) + .collect() +} + +fn invert_transcript(t: &Transcript) -> (Transcript, Vec>, Vec>) { + let mut protocol = Protocol::new(&t.domain); + let mut derived = Vec::new(); + let mut tagged = Vec::new(); + let inputs = t + .inputs + .iter() + .map(|op| match op { + Input::Mix(data) => { + protocol.mix(data); + Input::Mix(data.to_vec()) + } + Input::Derive(n) => { + let mut out = vec![0u8; *n]; + protocol.derive(&mut out); + derived.push(out); + Input::Derive(*n) + } + Input::Encrypt(plaintext) => { + let mut ciphertext = plaintext.clone(); + protocol.encrypt(&mut ciphertext); + Input::Decrypt(ciphertext) + } + Input::Decrypt(ciphertext) => { + let mut plaintext = ciphertext.clone(); + protocol.decrypt(&mut plaintext); + Input::Encrypt(plaintext) + } + Input::Tag => { + let mut tag = vec![0u8; TAG_LEN]; + protocol.tag(&mut tag); + tagged.push(tag); + Input::Tag + } + }) + .collect(); + + (Transcript { domain: t.domain.clone(), inputs }, derived, tagged) +} + +fn data() -> impl Strategy> { + vec(any::(), 0..200) +} + +fn input() -> impl Strategy { + prop_oneof![ + Just(Input::Tag), + (1usize..256).prop_map(Input::Derive), + data().prop_map(Input::Mix), + data().prop_map(Input::Encrypt), + data().prop_map(Input::Decrypt), + ] +} + +prop_compose! { + /// A transcript of 0..62 arbitrary operations terminated with a `Tag` operation to capture the + /// duplex's final state. + fn transcript()( + domain: String, + mut inputs in vec(input(), 0..62), + ) -> Transcript{ + inputs.push(Input::Tag); + Transcript{domain, inputs} + } +} + +proptest! { + /// Any two equal transcripts must produce equal outputs. Any two different transcripts must + /// produce different outputs. + #[test] + fn transcript_consistency(t0 in transcript(), t1 in transcript()) { + let out0 = apply_transcript(&t0); + let out1 = apply_transcript(&t1); + + if t0 == t1 { + prop_assert_eq!(out0, out1, "equal transcripts produced different outputs"); + } else { + prop_assert_ne!(out0, out1, "different transcripts produced equal outputs"); + } + } + + /// For any transcript, reversible outputs (e.g. encrypt/decrypt) must be symmetric. + #[test] + fn transcript_symmetry(t in transcript()) { + let (t_inv, a_d, a_t) = invert_transcript(&t); + let (t_p, b_d, b_t) = invert_transcript(&t_inv); + + prop_assert_eq!(t, t_p, "non-commutative transcript inversion"); + prop_assert_eq!(a_d, b_d, "divergent derived outputs"); + prop_assert_eq!(a_t, b_t, "divergent tag outputs"); + } +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..556d7840 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.65" +clap = { version = "4.0.2", features = ["derive"] } +xshell = "0.2.2" + +[[bin]] +name = "xtask" +path = "src/xtask.rs" diff --git a/xtask/src/xtask.rs b/xtask/src/xtask.rs new file mode 100644 index 00000000..efe2119c --- /dev/null +++ b/xtask/src/xtask.rs @@ -0,0 +1,43 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use xshell::{cmd, Shell}; + +#[derive(Debug, Parser)] +struct XTask { + #[clap(subcommand)] + cmd: Option, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Format, build, test, and lint. + CI, +} + +fn main() -> Result<()> { + let _ = XTask::parse(); + + let sh = Shell::new()?; + sh.change_dir(project_root()); + + cmd!(sh, "cargo fmt --check").run()?; + cmd!(sh, "cargo build --no-default-features").run()?; + cmd!(sh, "cargo build --all-targets --all-features").run()?; + cmd!(sh, "cargo test --all-features").run()?; + cmd!(sh, "cargo clippy --all-features --tests --benches").run()?; + + Ok(()) +} + +fn project_root() -> PathBuf { + Path::new( + &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()), + ) + .ancestors() + .nth(1) + .unwrap() + .to_path_buf() +}