From 5dc839cfa24253233656db81f6c38eda0f5e7443 Mon Sep 17 00:00:00 2001 From: Pierre Tholoniat CA Date: Mon, 9 Nov 2026 10:02:16 -0800 Subject: [PATCH] Cite Feldman 1987, shorten domain separators, and require reputable decryptor in Coordinator PiperOrigin-RevId: 1017434831 --- shell_wrapper/BUILD | 58 + shell_wrapper/hpke.cc | 363 +++++ shell_wrapper/hpke.h | 94 ++ shell_wrapper/hpke.rs | 257 +++ shell_wrapper/shell_types.cc | 11 + shell_wrapper/shell_types.h | 4 + shell_wrapper/shell_types.rs | 14 + willow/api/api_e2e_test.cc | 251 ++- willow/api/coordinator.cc | 72 + willow/api/coordinator.h | 40 +- willow/api/coordinator.rs | 140 +- willow/api/coordinator_test.cc | 102 ++ willow/api/server_accumulator.rs | 2 +- willow/crypto/BUILD | 60 + willow/crypto/ahe_traits.rs | 2 +- willow/crypto/feldman_vss.rs | 864 ++++++++++ willow/crypto/parameters_utils_test.cc | 11 + willow/crypto/share_cipher.rs | 663 ++++++++ willow/crypto/shell_ahe.rs | 2 +- willow/crypto/shell_parameters.rs | 100 +- willow/crypto/zk_rlwe_relation.rs | 11 +- willow/proto/willow/messages.proto | 114 ++ willow/protocol/BUILD | 28 + willow/protocol/coordinator.rs | 1412 ++++++++++++++++- willow/protocol/decryptor.rs | 1283 ++++++++++++++- willow/protocol/messages.rs | 1336 +++++++++++++++- willow/protocol/single_decryptor.rs | 17 +- willow/testing_utils/BUILD | 3 + .../testing_utils/shell_testing_decryptor.cc | 70 + .../testing_utils/shell_testing_decryptor.h | 31 + .../testing_utils/shell_testing_decryptor.rs | 217 ++- willow/tests/BUILD | 2 + willow/tests/e2e_shell.rs | 240 ++- 33 files changed, 7751 insertions(+), 123 deletions(-) create mode 100644 shell_wrapper/hpke.cc create mode 100644 shell_wrapper/hpke.h create mode 100644 shell_wrapper/hpke.rs create mode 100644 willow/crypto/feldman_vss.rs create mode 100644 willow/crypto/share_cipher.rs diff --git a/shell_wrapper/BUILD b/shell_wrapper/BUILD index 74da682..5cf056a 100644 --- a/shell_wrapper/BUILD +++ b/shell_wrapper/BUILD @@ -408,3 +408,61 @@ rust_test( "@crate_index//:googletest", ], ) + +# Tink HPKE Hybrid Encryption for peer share encryption +cc_library( + name = "hpke_cc", + srcs = ["hpke.cc"], + hdrs = ["hpke.h"], + deps = [ + ":hpke_cxx/include", + "@abseil-cpp//absl/base", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@cxx.rs//:core", + "//ffi_utils:cxx_utils", + "//ffi_utils:status_cc", + "//ffi_utils:status_cxx", + "@tink_cc//tink:aead", + "@tink_cc//tink:hybrid_decrypt", + "@tink_cc//tink:hybrid_encrypt", + "@tink_cc//tink:keyset_handle", + "@tink_cc//tink:proto_keyset_format", + "@tink_cc//tink/aead:aead_config", + "@tink_cc//tink/aead:aead_key_templates", + "@tink_cc//tink/config:global_registry", + "@tink_cc//tink/hybrid:hpke_config", + "@tink_cc//tink/hybrid:hybrid_key_templates", + ], +) + +rust_cxx_bridge( + name = "hpke_cxx", + src = "hpke.rs", + deps = [ + ":hpke", + ":hpke_cc", + "//ffi_utils:status_cxx", + "@tink_cc//tink:keyset_handle", + ], +) + +rust_library( + name = "hpke", + srcs = ["hpke.rs"], + deps = [ + "@cxx.rs//:cxx", + "//ffi_utils:status", + ], +) + +rust_test( + name = "hpke_test", + crate = ":hpke", + deps = [ + ":hpke_cxx", + "@crate_index//:googletest", + "//ffi_utils:status_matchers_rs", + ], +) diff --git a/shell_wrapper/hpke.cc b/shell_wrapper/hpke.cc new file mode 100644 index 0000000..57c5fdc --- /dev/null +++ b/shell_wrapper/hpke.cc @@ -0,0 +1,363 @@ +// Copyright 2026 Google LLC +// +// 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. + +#include "shell_wrapper/hpke.h" + +#include +#include +#include +#include +#include + +#include "absl/base/call_once.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "ffi_utils/cxx_utils.h" +#include "ffi_utils/status.h" +#include "include/cxx.h" +#include "shell_wrapper/hpke.rs.h" +#include "tink/aead.h" +#include "tink/aead/aead_config.h" +#include "tink/aead/aead_key_templates.h" +#include "tink/config/global_registry.h" +#include "tink/hybrid/hpke_config.h" +#include "tink/hybrid/hybrid_key_templates.h" +#include "tink/hybrid_decrypt.h" +#include "tink/hybrid_encrypt.h" +#include "tink/keyset_handle.h" +#include "tink/proto_keyset_format.h" + +namespace secure_aggregation { +namespace { + +absl::Status EnsureTinkHpkeRegistered() { + static absl::once_flag register_once; + static absl::Status* register_status = nullptr; + absl::call_once(register_once, []() { + absl::Status status = crypto::tink::RegisterHpke(); + if (status.ok()) { + status = crypto::tink::AeadConfig::Register(); + } + register_status = new absl::Status(std::move(status)); + }); + return *register_status; +} + +// Enforces that an HPKE or AEAD keyset handle has been validated by Tink and +// contains exactly one enabled primary key. Combined with +// `EnsureTinkHpkeRegistered()` (which registers only `RegisterHpke()` for +// `HybridEncrypt`/`HybridDecrypt`, never ECIES `HybridConfig::Register()`), +// `GetPrimitive` / `GetPrimitive` accepts +// exclusively single-key HPKE keysets. +absl::Status ValidateSingleKeyKeyset( + const crypto::tink::KeysetHandle& keyset_handle) { + absl::Status validate_status = keyset_handle.Validate(); + if (!validate_status.ok()) { + return validate_status; + } + if (keyset_handle.size() != 1) { + return absl::InvalidArgumentError( + "Keyset must contain exactly one enabled primary key"); + } + return absl::OkStatus(); +} + +} // namespace + +FfiStatus GenerateHpkeKeyset( + bool raw_output_prefix, HpkeKeysetWrapper& private_keyset_out, + std::unique_ptr& serialized_public_keyset_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + + const auto& key_template = + raw_output_prefix + ? crypto::tink::HybridKeyTemplates::HpkeX25519HkdfSha256Aes128GcmRaw() + : crypto::tink::HybridKeyTemplates::HpkeX25519HkdfSha256Aes128Gcm(); + + absl::StatusOr> private_handle = + crypto::tink::KeysetHandle::GenerateNew( + key_template, crypto::tink::KeyGenConfigGlobalRegistry()); + if (!private_handle.ok()) { + return MakeFfiStatus(std::move(private_handle).status()); + } + + absl::StatusOr> public_handle = + (*private_handle) + ->GetPublicKeysetHandle(crypto::tink::KeyGenConfigGlobalRegistry()); + if (!public_handle.ok()) { + return MakeFfiStatus(std::move(public_handle).status()); + } + + absl::StatusOr serialized_public = + crypto::tink::SerializeKeysetWithoutSecretToProtoKeysetFormat( + **public_handle); + if (!serialized_public.ok()) { + return MakeFfiStatus(std::move(serialized_public).status()); + } + + private_keyset_out.ptr = *std::move(private_handle); + serialized_public_keyset_out = + std::make_unique(*std::move(serialized_public)); + return MakeFfiStatus(); +} + +FfiStatus GetPublicKeysetFromPrivate( + const HpkeKeysetWrapper& private_keyset, + std::unique_ptr& serialized_public_keyset_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (private_keyset.ptr == nullptr) { + return MakeFfiStatus( + absl::InvalidArgumentError("HPKE private keyset handle is null")); + } + + absl::StatusOr> public_handle = + private_keyset.ptr->GetPublicKeysetHandle( + crypto::tink::KeyGenConfigGlobalRegistry()); + if (!public_handle.ok()) { + return MakeFfiStatus(std::move(public_handle).status()); + } + + absl::StatusOr serialized_public = + crypto::tink::SerializeKeysetWithoutSecretToProtoKeysetFormat( + **public_handle); + if (!serialized_public.ok()) { + return MakeFfiStatus(std::move(serialized_public).status()); + } + + serialized_public_keyset_out = + std::make_unique(*std::move(serialized_public)); + return MakeFfiStatus(); +} + +void CloneHpkeKeyset(const HpkeKeysetWrapper& src, HpkeKeysetWrapper& dst) { + if (src.ptr == nullptr) { + dst.ptr = nullptr; + } else { + dst.ptr = std::make_unique(*src.ptr); + } +} + +FfiStatus ValidateHpkePublicKeyset( + rust::Slice serialized_public_keyset) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (serialized_public_keyset.empty()) { + return MakeFfiStatus( + absl::InvalidArgumentError("Serialized HPKE public keyset is empty")); + } + + absl::StatusOr public_handle = + crypto::tink::ParseKeysetWithoutSecretFromProtoKeysetFormat( + ToAbslStringView(serialized_public_keyset)); + if (!public_handle.ok()) { + return MakeFfiStatus(std::move(public_handle).status()); + } + absl::Status single_key_status = ValidateSingleKeyKeyset(*public_handle); + if (!single_key_status.ok()) { + return MakeFfiStatus(std::move(single_key_status)); + } + + absl::StatusOr> hybrid_encrypt = + public_handle->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!hybrid_encrypt.ok()) { + return MakeFfiStatus(std::move(hybrid_encrypt).status()); + } + return MakeFfiStatus(); +} + +FfiStatus HpkeEncrypt(rust::Slice serialized_public_keyset, + rust::Slice plaintext, + rust::Slice context_info, + std::unique_ptr& ciphertext_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (serialized_public_keyset.empty()) { + return MakeFfiStatus( + absl::InvalidArgumentError("Serialized HPKE public keyset is empty")); + } + + absl::StatusOr public_handle = + crypto::tink::ParseKeysetWithoutSecretFromProtoKeysetFormat( + ToAbslStringView(serialized_public_keyset)); + if (!public_handle.ok()) { + return MakeFfiStatus(std::move(public_handle).status()); + } + absl::Status single_key_status = ValidateSingleKeyKeyset(*public_handle); + if (!single_key_status.ok()) { + return MakeFfiStatus(std::move(single_key_status)); + } + + absl::StatusOr> hybrid_encrypt = + public_handle->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!hybrid_encrypt.ok()) { + return MakeFfiStatus(std::move(hybrid_encrypt).status()); + } + + absl::StatusOr ciphertext = + (*hybrid_encrypt) + ->Encrypt(ToAbslStringView(plaintext), + ToAbslStringView(context_info)); + if (!ciphertext.ok()) { + return MakeFfiStatus(std::move(ciphertext).status()); + } + + ciphertext_out = std::make_unique(*std::move(ciphertext)); + return MakeFfiStatus(); +} + +FfiStatus HpkeDecrypt(const HpkeKeysetWrapper& private_keyset, + rust::Slice ciphertext, + rust::Slice context_info, + std::unique_ptr& plaintext_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (private_keyset.ptr == nullptr) { + return MakeFfiStatus( + absl::InvalidArgumentError("HPKE private keyset handle is null")); + } + absl::Status single_key_status = ValidateSingleKeyKeyset(*private_keyset.ptr); + if (!single_key_status.ok()) { + return MakeFfiStatus(std::move(single_key_status)); + } + + absl::StatusOr> hybrid_decrypt = + private_keyset.ptr->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!hybrid_decrypt.ok()) { + return MakeFfiStatus(std::move(hybrid_decrypt).status()); + } + + absl::StatusOr plaintext = + (*hybrid_decrypt) + ->Decrypt(ToAbslStringView(ciphertext), + ToAbslStringView(context_info)); + if (!plaintext.ok()) { + return MakeFfiStatus(std::move(plaintext).status()); + } + + plaintext_out = std::make_unique(*std::move(plaintext)); + return MakeFfiStatus(); +} + +FfiStatus GenerateHpkeKeysetWrappingAead(HpkeKeysetWrapper& aead_keyset_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + + absl::StatusOr> aead_handle = + crypto::tink::KeysetHandle::GenerateNew( + crypto::tink::AeadKeyTemplates::Aes256Gcm(), + crypto::tink::KeyGenConfigGlobalRegistry()); + if (!aead_handle.ok()) { + return MakeFfiStatus(std::move(aead_handle).status()); + } + + aead_keyset_out.ptr = *std::move(aead_handle); + return MakeFfiStatus(); +} + +FfiStatus SerializeHpkePrivateKeysetEncrypted( + const HpkeKeysetWrapper& private_keyset, + const HpkeKeysetWrapper& aead_keyset, + rust::Slice associated_data, + std::unique_ptr& encrypted_keyset_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (private_keyset.ptr == nullptr || aead_keyset.ptr == nullptr) { + return MakeFfiStatus( + absl::InvalidArgumentError("HPKE or AEAD keyset handle is null")); + } + + absl::StatusOr> aead = + aead_keyset.ptr->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!aead.ok()) { + return MakeFfiStatus(std::move(aead).status()); + } + + absl::StatusOr encrypted = + crypto::tink::SerializeKeysetToEncryptedKeysetFormat( + *private_keyset.ptr, **aead, ToAbslStringView(associated_data)); + if (!encrypted.ok()) { + return MakeFfiStatus(std::move(encrypted).status()); + } + + encrypted_keyset_out = std::make_unique(*std::move(encrypted)); + return MakeFfiStatus(); +} + +FfiStatus ParseHpkePrivateKeysetEncrypted( + rust::Slice encrypted_keyset, + const HpkeKeysetWrapper& aead_keyset, + rust::Slice associated_data, + HpkeKeysetWrapper& private_keyset_out) { + absl::Status reg_status = EnsureTinkHpkeRegistered(); + if (!reg_status.ok()) { + return MakeFfiStatus(std::move(reg_status)); + } + if (aead_keyset.ptr == nullptr) { + return MakeFfiStatus( + absl::InvalidArgumentError("AEAD keyset handle is null")); + } + + absl::StatusOr> aead = + aead_keyset.ptr->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!aead.ok()) { + return MakeFfiStatus(std::move(aead).status()); + } + + absl::StatusOr parsed = + crypto::tink::ParseKeysetFromEncryptedKeysetFormat( + ToAbslStringView(encrypted_keyset), **aead, + ToAbslStringView(associated_data)); + if (!parsed.ok()) { + return MakeFfiStatus(std::move(parsed).status()); + } + absl::Status single_key_status = ValidateSingleKeyKeyset(*parsed); + if (!single_key_status.ok()) { + return MakeFfiStatus(std::move(single_key_status)); + } + + absl::StatusOr> check_decrypt = + parsed->GetPrimitive( + crypto::tink::ConfigGlobalRegistry()); + if (!check_decrypt.ok()) { + return MakeFfiStatus(std::move(check_decrypt).status()); + } + + private_keyset_out.ptr = + std::make_unique(*std::move(parsed)); + return MakeFfiStatus(); +} + +} // namespace secure_aggregation diff --git a/shell_wrapper/hpke.h b/shell_wrapper/hpke.h new file mode 100644 index 0000000..3dcb734 --- /dev/null +++ b/shell_wrapper/hpke.h @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Google LLC + * + * 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. + */ + +#ifndef SECURE_AGGREGATION_SHELL_WRAPPER_HPKE_H_ +#define SECURE_AGGREGATION_SHELL_WRAPPER_HPKE_H_ + +#include +#include +#include + +#include "ffi_utils/status.h" +#include "ffi_utils/status.rs.h" +#include "include/cxx.h" +#include "shell_wrapper/hpke.rs.h" +#include "tink/keyset_handle.h" + +namespace secure_aggregation { + +// Generates a new Tink HPKE (DHKEM_X25519_HKDF_SHA256 / HKDF_SHA256 / +// AES_128_GCM) private keyset handle and its serialized public keyset in +// Tink ProtoKeysetFormat (`SerializeKeysetWithoutSecretToProtoKeysetFormat`). +// If `raw_output_prefix` is true, uses `HpkeX25519HkdfSha256Aes128GcmRaw()`; +// otherwise uses `HpkeX25519HkdfSha256Aes128Gcm()` (`TINK` 5-byte prefix). +FfiStatus GenerateHpkeKeyset( + bool raw_output_prefix, HpkeKeysetWrapper& private_keyset_out, + std::unique_ptr& serialized_public_keyset_out); + +// Extracts and serializes the public keyset from an existing HPKE private +// keyset handle using `SerializeKeysetWithoutSecretToProtoKeysetFormat`. +FfiStatus GetPublicKeysetFromPrivate( + const HpkeKeysetWrapper& private_keyset, + std::unique_ptr& serialized_public_keyset_out); + +// Deep-copies an `HpkeKeysetWrapper`. +void CloneHpkeKeyset(const HpkeKeysetWrapper& src, HpkeKeysetWrapper& dst); + +// Validates that `serialized_public_keyset` parses via +// `ParseKeysetWithoutSecretFromProtoKeysetFormat` and produces a valid +// `crypto::tink::HybridEncrypt` primitive. +FfiStatus ValidateHpkePublicKeyset( + rust::Slice serialized_public_keyset); + +// Encrypts `plaintext` with `context_info` under `serialized_public_keyset` +// using Tink's `HybridEncrypt`. +FfiStatus HpkeEncrypt(rust::Slice serialized_public_keyset, + rust::Slice plaintext, + rust::Slice context_info, + std::unique_ptr& ciphertext_out); + +// Decrypts `ciphertext` with `context_info` using `private_keyset`'s +// `HybridDecrypt` primitive. +FfiStatus HpkeDecrypt(const HpkeKeysetWrapper& private_keyset, + rust::Slice ciphertext, + rust::Slice context_info, + std::unique_ptr& plaintext_out); + +// Generates an AES-256-GCM Tink AEAD keyset handle for encrypting HPKE +// private keysets via `SerializeKeysetToEncryptedKeysetFormat`. +FfiStatus GenerateHpkeKeysetWrappingAead(HpkeKeysetWrapper& aead_keyset_out); + +// Serializes `private_keyset` to Tink EncryptedKeysetFormat under +// `aead_keyset` and `associated_data` +// (`SerializeKeysetToEncryptedKeysetFormat`). +FfiStatus SerializeHpkePrivateKeysetEncrypted( + const HpkeKeysetWrapper& private_keyset, + const HpkeKeysetWrapper& aead_keyset, + rust::Slice associated_data, + std::unique_ptr& encrypted_keyset_out); + +// Parses an encrypted HPKE private keyset using +// `ParseKeysetFromEncryptedKeysetFormat` and verifies it supports +// `HybridDecrypt`. +FfiStatus ParseHpkePrivateKeysetEncrypted( + rust::Slice encrypted_keyset, + const HpkeKeysetWrapper& aead_keyset, + rust::Slice associated_data, + HpkeKeysetWrapper& private_keyset_out); + +} // namespace secure_aggregation + +#endif // SECURE_AGGREGATION_SHELL_WRAPPER_HPKE_H_ diff --git a/shell_wrapper/hpke.rs b/shell_wrapper/hpke.rs new file mode 100644 index 0000000..a925319 --- /dev/null +++ b/shell_wrapper/hpke.rs @@ -0,0 +1,257 @@ +// Copyright 2026 Google LLC +// +// 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. + +//! Rust FFI wrapper around Tink C++ HPKE (`DHKEM_X25519_HKDF_SHA256`, +//! `HKDF_SHA256`, `AES_128_GCM`) using only `//visibility:public` Tink targets. + +use status::{rust_status_from_cpp, StatusError}; + +#[cxx::bridge(namespace = "secure_aggregation")] +mod ffi { + struct HpkeKeysetWrapper { + ptr: UniquePtr, + } + + unsafe extern "C++" { + type FfiStatus = status::ffi::FfiStatus; + + include!("shell_wrapper/hpke.h"); + + #[namespace = "crypto::tink"] + type KeysetHandle; + + pub fn GenerateHpkeKeyset( + raw_output_prefix: bool, + private_keyset_out: &mut HpkeKeysetWrapper, + serialized_public_keyset_out: &mut UniquePtr, + ) -> FfiStatus; + + pub fn GetPublicKeysetFromPrivate( + private_keyset: &HpkeKeysetWrapper, + serialized_public_keyset_out: &mut UniquePtr, + ) -> FfiStatus; + + pub fn CloneHpkeKeyset(src: &HpkeKeysetWrapper, dst: &mut HpkeKeysetWrapper); + + pub fn ValidateHpkePublicKeyset(serialized_public_keyset: &[u8]) -> FfiStatus; + + pub fn HpkeEncrypt( + serialized_public_keyset: &[u8], + plaintext: &[u8], + context_info: &[u8], + ciphertext_out: &mut UniquePtr, + ) -> FfiStatus; + + pub fn HpkeDecrypt( + private_keyset: &HpkeKeysetWrapper, + ciphertext: &[u8], + context_info: &[u8], + plaintext_out: &mut UniquePtr, + ) -> FfiStatus; + + pub fn GenerateHpkeKeysetWrappingAead(aead_keyset_out: &mut HpkeKeysetWrapper) + -> FfiStatus; + + pub fn SerializeHpkePrivateKeysetEncrypted( + private_keyset: &HpkeKeysetWrapper, + aead_keyset: &HpkeKeysetWrapper, + associated_data: &[u8], + encrypted_keyset_out: &mut UniquePtr, + ) -> FfiStatus; + + pub fn ParseHpkePrivateKeysetEncrypted( + encrypted_keyset: &[u8], + aead_keyset: &HpkeKeysetWrapper, + associated_data: &[u8], + private_keyset_out: &mut HpkeKeysetWrapper, + ) -> FfiStatus; + } +} + +/// Opaque handle to a Tink HPKE private `KeysetHandle` (or wrapping AEAD `KeysetHandle`). +pub struct HpkeKeyset(ffi::HpkeKeysetWrapper); + +impl Clone for HpkeKeyset { + fn clone(&self) -> Self { + let mut dst = ffi::HpkeKeysetWrapper { ptr: cxx::UniquePtr::null() }; + ffi::CloneHpkeKeyset(&self.0, &mut dst); + Self(dst) + } +} + +impl std::fmt::Debug for HpkeKeyset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HpkeKeyset([REDACTED])") + } +} + +/// Generates a new HPKE keypair (`HpkeX25519HkdfSha256Aes128Gcm` or `Raw`) and returns the +/// private `HpkeKeyset` handle along with the serialized public keyset in Tink ProtoKeysetFormat. +pub fn generate_hpke_keyset(raw_output_prefix: bool) -> Result<(HpkeKeyset, Vec), StatusError> { + let mut private_keyset = ffi::HpkeKeysetWrapper { ptr: cxx::UniquePtr::null() }; + let mut public_bytes = cxx::UniquePtr::null(); + rust_status_from_cpp(ffi::GenerateHpkeKeyset( + raw_output_prefix, + &mut private_keyset, + &mut public_bytes, + ))?; + let pub_vec = public_bytes.as_ref().map(|s| s.as_bytes().to_vec()).unwrap_or_default(); + Ok((HpkeKeyset(private_keyset), pub_vec)) +} + +/// Extracts and serializes the public keyset from an existing `HpkeKeyset`. +pub fn get_public_keyset_from_private(private_keyset: &HpkeKeyset) -> Result, StatusError> { + let mut public_bytes = cxx::UniquePtr::null(); + rust_status_from_cpp(ffi::GetPublicKeysetFromPrivate(&private_keyset.0, &mut public_bytes))?; + Ok(public_bytes.as_ref().map(|s| s.as_bytes().to_vec()).unwrap_or_default()) +} + +/// Validates that `serialized_public_keyset` is a valid Tink `HybridEncrypt` public keyset. +pub fn validate_hpke_public_keyset(serialized_public_keyset: &[u8]) -> status::Status { + rust_status_from_cpp(ffi::ValidateHpkePublicKeyset(serialized_public_keyset)) +} + +/// Encrypts `plaintext` with `context_info` under `serialized_public_keyset`. +pub fn hpke_encrypt( + serialized_public_keyset: &[u8], + plaintext: &[u8], + context_info: &[u8], +) -> Result, StatusError> { + let mut ciphertext = cxx::UniquePtr::null(); + rust_status_from_cpp(ffi::HpkeEncrypt( + serialized_public_keyset, + plaintext, + context_info, + &mut ciphertext, + ))?; + Ok(ciphertext.as_ref().map(|s| s.as_bytes().to_vec()).unwrap_or_default()) +} + +/// Decrypts `ciphertext` with `context_info` using `private_keyset`. +pub fn hpke_decrypt( + private_keyset: &HpkeKeyset, + ciphertext: &[u8], + context_info: &[u8], +) -> Result, StatusError> { + let mut plaintext = cxx::UniquePtr::null(); + rust_status_from_cpp(ffi::HpkeDecrypt( + &private_keyset.0, + ciphertext, + context_info, + &mut plaintext, + ))?; + Ok(plaintext.as_ref().map(|s| s.as_bytes().to_vec()).unwrap_or_default()) +} + +/// Generates a fresh AES-256-GCM AEAD keyset handle for wrapping HPKE private keysets. +pub fn generate_wrapping_aead() -> Result { + let mut aead_keyset = ffi::HpkeKeysetWrapper { ptr: cxx::UniquePtr::null() }; + rust_status_from_cpp(ffi::GenerateHpkeKeysetWrappingAead(&mut aead_keyset))?; + Ok(HpkeKeyset(aead_keyset)) +} + +/// Serializes `private_keyset` to Tink EncryptedKeysetFormat under `aead_keyset` and +/// `associated_data` (`SerializeKeysetToEncryptedKeysetFormat`). +pub fn serialize_hpke_private_keyset_encrypted( + private_keyset: &HpkeKeyset, + aead_keyset: &HpkeKeyset, + associated_data: &[u8], +) -> Result, StatusError> { + let mut encrypted = cxx::UniquePtr::null(); + rust_status_from_cpp(ffi::SerializeHpkePrivateKeysetEncrypted( + &private_keyset.0, + &aead_keyset.0, + associated_data, + &mut encrypted, + ))?; + Ok(encrypted.as_ref().map(|s| s.as_bytes().to_vec()).unwrap_or_default()) +} + +/// Parses an encrypted HPKE private keyset using `ParseKeysetFromEncryptedKeysetFormat`. +pub fn parse_hpke_private_keyset_encrypted( + encrypted_keyset: &[u8], + aead_keyset: &HpkeKeyset, + associated_data: &[u8], +) -> Result { + let mut private_keyset = ffi::HpkeKeysetWrapper { ptr: cxx::UniquePtr::null() }; + rust_status_from_cpp(ffi::ParseHpkePrivateKeysetEncrypted( + encrypted_keyset, + &aead_keyset.0, + associated_data, + &mut private_keyset, + ))?; + Ok(HpkeKeyset(private_keyset)) +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::{gtest, verify_eq, verify_that, Result as GTestResult}; + use status::StatusErrorCode; + use status_matchers_rs::status_is; + + #[gtest] + fn test_hpke_roundtrip_tink_and_raw_templates() -> GTestResult<()> { + for raw_prefix in [false, true] { + let (sk, pk) = generate_hpke_keyset(raw_prefix)?; + validate_hpke_public_keyset(&pk)?; + let derived_pk = get_public_keyset_from_private(&sk)?; + verify_eq!(derived_pk, pk)?; + + let msg = b"32-byte-secret-scalar-share-test"; + let info = b"ShareCipher.hpke_ctx"; + let ct = hpke_encrypt(&pk, msg, info)?; + let pt = hpke_decrypt(&sk, &ct, info)?; + verify_eq!(pt, msg)?; + + // Cloned private keyset also decrypts. + let sk_clone = sk.clone(); + let pt_clone = hpke_decrypt(&sk_clone, &ct, info)?; + verify_eq!(pt_clone, msg)?; + + // Wrong context_info fails closed. + verify_that!( + hpke_decrypt(&sk, &ct, b"wrong_ctx"), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + } + Ok(()) + } + + #[gtest] + fn test_encrypted_keyset_serialization_roundtrip() -> GTestResult<()> { + let (sk, pk) = generate_hpke_keyset(false)?; + let aead = generate_wrapping_aead()?; + let ad = b"decryptor-state-ad"; + + let encrypted = serialize_hpke_private_keyset_encrypted(&sk, &aead, ad)?; + let restored_sk = parse_hpke_private_keyset_encrypted(&encrypted, &aead, ad)?; + + let ct = hpke_encrypt(&pk, b"hello-hpke", b"ctx")?; + let pt = hpke_decrypt(&restored_sk, &ct, b"ctx")?; + verify_eq!(pt, b"hello-hpke")?; + + // Wrong associated data or wrong AEAD key fails closed. + verify_that!( + parse_hpke_private_keyset_encrypted(&encrypted, &aead, b"wrong-ad"), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + let other_aead = generate_wrapping_aead()?; + verify_that!( + parse_hpke_private_keyset_encrypted(&encrypted, &other_aead, ad), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + Ok(()) + } +} diff --git a/shell_wrapper/shell_types.cc b/shell_wrapper/shell_types.cc index d04c83f..98e5d68 100644 --- a/shell_wrapper/shell_types.cc +++ b/shell_wrapper/shell_types.cc @@ -273,6 +273,17 @@ RnsPolynomialWrapper CloneRnsPolynomialWrapper(const RnsPolynomialWrapper* in) { in->ptr->Clone())}; } +bool RnsPolynomialWrapperEquals(const RnsPolynomialWrapper* lhs, + const RnsPolynomialWrapper* rhs) { + if (lhs == nullptr || rhs == nullptr) { + return lhs == rhs; + } + if (lhs->ptr == nullptr || rhs->ptr == nullptr) { + return lhs->ptr == rhs->ptr; + } + return *lhs->ptr == *rhs->ptr; +} + RnsPolynomialVecWrapper CloneRnsPolynomialVecWrapper( const RnsPolynomialVecWrapper* in) { RnsPolynomialVecWrapper result{ diff --git a/shell_wrapper/shell_types.h b/shell_wrapper/shell_types.h index 0b30817..2b488f0 100644 --- a/shell_wrapper/shell_types.h +++ b/shell_wrapper/shell_types.h @@ -57,6 +57,10 @@ inline RnsPolynomialWrapper CreateEmptyRnsPolynomialWrapper() { // must only be called from there. RnsPolynomialWrapper CloneRnsPolynomialWrapper(const RnsPolynomialWrapper* in); +// Returns true iff `lhs` and `rhs` wrap equal `RnsPolynomial` instances. +bool RnsPolynomialWrapperEquals(const RnsPolynomialWrapper* lhs, + const RnsPolynomialWrapper* rhs); + // Clones the RnsPolynomialVecWrapper. Used to implement Rust's Clone trait, and // must only be called from there. RnsPolynomialVecWrapper CloneRnsPolynomialVecWrapper( diff --git a/shell_wrapper/shell_types.rs b/shell_wrapper/shell_types.rs index 2ec29e5..abfbfd2 100644 --- a/shell_wrapper/shell_types.rs +++ b/shell_wrapper/shell_types.rs @@ -87,6 +87,11 @@ pub mod ffi { poly: *const RnsPolynomialWrapper, ) -> RnsPolynomialWrapper; + pub unsafe fn RnsPolynomialWrapperEquals( + lhs: *const RnsPolynomialWrapper, + rhs: *const RnsPolynomialWrapper, + ) -> bool; + pub unsafe fn CloneRnsPolynomialVecWrapper( poly: *const RnsPolynomialVecWrapper, ) -> RnsPolynomialVecWrapper; @@ -139,6 +144,15 @@ impl Clone for RnsPolynomial { } } +impl PartialEq for RnsPolynomial { + fn eq(&self, other: &Self) -> bool { + // SAFETY: `self` and `other` are valid `RnsPolynomialWrapper` references. + unsafe { ffi::RnsPolynomialWrapperEquals(self, other) } + } +} + +impl Eq for RnsPolynomial {} + pub use ffi::CreateEmptyRnsPolynomialWrapper as create_empty_rns_polynomial; /// Store some raw pointers to the prime moduli. The bindings don't know that diff --git a/willow/api/api_e2e_test.cc b/willow/api/api_e2e_test.cc index d32d771..5664515 100644 --- a/willow/api/api_e2e_test.cc +++ b/willow/api/api_e2e_test.cc @@ -60,11 +60,7 @@ TEST(WillowV1ApiE2ETest, MultiDecryptorCoordinatorFullPipelineWithCodec) { CodecFactory::CreateExplicitCodec(input_spec)); constexpr int64_t kMaxClients = 10; - // The parameters used by `CreateAggregationConfig` below are secure for only - // a single decryptor when assuming all clients are corrupted. As we are - // testing functionality here anyway, we set `max_number_of_decryptors` to 1 - // just to obtain the AHE parameters. - constexpr int64_t kMaxDecryptors = 1; + constexpr int64_t kMaxDecryptors = 3; constexpr int64_t kMaxDecryptorDropouts = 0; constexpr int64_t kDefaultMaxMetricValue = 100; @@ -269,6 +265,251 @@ TEST(WillowV1ApiE2ETest, MultiDecryptorCoordinatorFullPipelineWithCodec) { Pair("lang", ElementsAre("es", "en", "en", "en", "es")))); } +TEST(WillowV1ApiE2ETest, + RecoverableMultiDecryptorCoordinatorFullPipelineWithDropoutRecovery) { + InputSpec input_spec = CreateTestInputSpecProto(); + SECAGG_ASSERT_OK_AND_ASSIGN(auto encoder, + CodecFactory::CreateExplicitCodec(input_spec)); + + // Configure a 3-of-5 recoverable committee (tolerating up to d = 5 - 3 = 2 + // dropped non-reputable decryptors) plus 1 reputable decryptor. + constexpr uint32_t kNumCommitteeDecryptors = 5; + constexpr uint32_t kThreshold = 3; + constexpr absl::string_view kSessionContext = + "api_e2e_recoverable_3_of_5_session"; + + constexpr int64_t kMaxClients = 10; + constexpr int64_t kMaxDecryptors = 1 + kNumCommitteeDecryptors; + constexpr int64_t kMaxDecryptorDropouts = + kNumCommitteeDecryptors - kThreshold; + constexpr int64_t kDefaultMaxMetricValue = 100; + + SECAGG_ASSERT_OK_AND_ASSIGN( + AggregationConfigProto config, + CreateAggregationConfig(input_spec, "test_key_recoverable", kMaxClients, + kMaxDecryptors, kMaxDecryptorDropouts, + kDefaultMaxMetricValue)); + + SECAGG_ASSERT_OK_AND_ASSIGN(auto coordinator, Coordinator::Create(config)); + SECAGG_ASSERT_OK_AND_ASSIGN(auto reputable_decryptor, + ShellTestingDecryptor::Create(config)); + + std::vector> committee_decryptors; + std::vector roster_pks; + for (uint32_t i = 1; i <= kNumCommitteeDecryptors; ++i) { + SECAGG_ASSERT_OK_AND_ASSIGN(auto dec, + ShellTestingDecryptor::Create(config)); + SECAGG_ASSERT_OK_AND_ASSIGN(std::string pk, dec->GenerateHpkePublicKey()); + roster_pks.push_back(std::move(pk)); + committee_decryptors.push_back(std::move(dec)); + } + + SECAGG_ASSERT_OK_AND_ASSIGN(CommitteeConfigProto coord_committee_cfg, + ShellTestingDecryptor::BuildCommitteeConfig( + /*my_index=*/1, kNumCommitteeDecryptors, + kThreshold, kSessionContext, roster_pks)); + SECAGG_ASSERT_OK(coordinator->ConfigureCommittee(coord_committee_cfg)); + + // Generate setup contributions (1 reputable + 5 recoverable committee). + SECAGG_ASSERT_OK_AND_ASSIGN(SetupContribution reputable_contrib, + reputable_decryptor->CreateSetupContribution()); + std::vector reputable_contribs = { + std::move(reputable_contrib)}; + + std::vector non_reputable_contribs; + for (uint32_t i = 1; i <= kNumCommitteeDecryptors; ++i) { + SECAGG_ASSERT_OK_AND_ASSIGN(CommitteeConfigProto dec_cfg, + ShellTestingDecryptor::BuildCommitteeConfig( + i, kNumCommitteeDecryptors, kThreshold, + kSessionContext, roster_pks)); + SECAGG_ASSERT_OK_AND_ASSIGN( + SetupContribution contrib, + committee_decryptors[i - 1]->CreateRecoverableSetupContribution( + dec_cfg, roster_pks)); + non_reputable_contribs.push_back(std::move(contrib)); + } + + SECAGG_ASSERT_OK_AND_ASSIGN(VerifyKeyContributionsRequest verify_request, + coordinator->HandleSetupSubmissions( + non_reputable_contribs, reputable_contribs)); + + // Serialize and reinstantiate Coordinator after setup round. + SECAGG_ASSERT_OK_AND_ASSIGN(std::string serialized_coord_state, + coordinator->ToSerializedState()); + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, + Coordinator::CreateFromSerializedState(serialized_coord_state)); + + // Reputable decryptor verifies all 6 ZK proofs and aggregates global PK. + SECAGG_ASSERT_OK_AND_ASSIGN( + ShellAhePublicKey public_key, + reputable_decryptor->VerifyAndAggregateKeyContributions(verify_request)); + + // Encode inputs for 3 clients and accumulate across 2 ServerAccumulators. + GroupData group_by_data0 = {{"country", {"US", "CA", "MX"}}, + {"lang", {"en", "es", "en"}}}; + MetricData metric_data0 = {{"metric1", {17, 42, 10}}}; + + GroupData group_by_data1 = {{"country", {"US", "US", "MX"}}, + {"lang", {"en", "es", "en"}}}; + MetricData metric_data1 = {{"metric1", {23, 83, 29}}}; + + GroupData group_by_data2 = {{"country", {"CA", "GB", "US"}}, + {"lang", {"es", "en", "es"}}}; + MetricData metric_data2 = {{"metric1", {54, 61, 15}}}; + + SECAGG_ASSERT_OK_AND_ASSIGN(EncodedData encoded0, + encoder->Encode(group_by_data0, metric_data0)); + SECAGG_ASSERT_OK_AND_ASSIGN(EncodedData encoded1, + encoder->Encode(group_by_data1, metric_data1)); + SECAGG_ASSERT_OK_AND_ASSIGN(EncodedData encoded2, + encoder->Encode(group_by_data2, metric_data2)); + + SECAGG_ASSERT_OK_AND_ASSIGN( + ClientMessage msg0, + GenerateClientContribution(config, encoded0, public_key, + PrefixedNonce("nonce0"))); + SECAGG_ASSERT_OK_AND_ASSIGN( + ClientMessage msg1, + GenerateClientContribution(config, encoded1, public_key, + PrefixedNonce("nonce1"))); + SECAGG_ASSERT_OK_AND_ASSIGN( + ClientMessage msg2, + GenerateClientContribution(config, encoded2, public_key, + PrefixedNonce("nonce2"))); + + SECAGG_ASSERT_OK_AND_ASSIGN(auto acc1, ServerAccumulator::Create(config)); + SECAGG_ASSERT_OK_AND_ASSIGN(auto acc2, ServerAccumulator::Create(config)); + + ClientMessageRange range1; + *range1.add_client_messages() = std::move(msg0); + *range1.add_client_messages() = std::move(msg1); + range1.mutable_nonce_range()->set_start("nonce0"); + range1.mutable_nonce_range()->set_end("nonce2"); + + ClientMessageRange range2; + *range2.add_client_messages() = std::move(msg2); + range2.mutable_nonce_range()->set_start("nonce2"); + range2.mutable_nonce_range()->set_end("nonce3"); + + SECAGG_ASSERT_OK(acc1->ProcessClientMessages(range1)); + SECAGG_ASSERT_OK(acc2->ProcessClientMessages(range2)); + SECAGG_ASSERT_OK(acc1->Merge(std::move(acc2))); + + SECAGG_ASSERT_OK_AND_ASSIGN( + FinalizedAccumulatorResult finalized_accumulator_result, + std::move(*acc1).Finalize()); + + PartialDecryptionRequest acc_dec_req; + ASSERT_TRUE(acc_dec_req.ParseFromString( + finalized_accumulator_result.decryption_request())); + + SECAGG_ASSERT_OK_AND_ASSIGN(PartialDecryptionRequest dec_request, + coordinator->PrepareDecryptionRequest( + acc_dec_req.partial_dec_ciphertext())); + + // Serialize and reinstantiate Coordinator while awaiting partial decryptions. + SECAGG_ASSERT_OK_AND_ASSIGN(serialized_coord_state, + coordinator->ToSerializedState()); + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, + Coordinator::CreateFromSerializedState(serialized_coord_state)); + + // Committee decryptors {2, 5} DROP OUT! Only survivors {1, 3, 4} (t = 3) + // and the reputable decryptor respond to the partial decryption request. + std::vector partial_responses; + SECAGG_ASSERT_OK_AND_ASSIGN( + PartialDecryptionResponse response_reputable, + reputable_decryptor->HandlePartialDecryptionRequest(dec_request)); + partial_responses.push_back(std::move(response_reputable)); + + for (uint32_t survivor_idx : {1u, 3u, 4u}) { + SECAGG_ASSERT_OK_AND_ASSIGN( + PartialDecryptionResponse resp, + committee_decryptors[survivor_idx - 1]->HandlePartialDecryptionRequest( + dec_request)); + EXPECT_EQ(resp.decryptor_index(), survivor_idx); + partial_responses.push_back(std::move(resp)); + } + + SECAGG_ASSERT_OK(coordinator->AggregatePartialDecryptions(partial_responses)); + + // Serialize and reinstantiate Coordinator in AwaitingRecovery state! + SECAGG_ASSERT_OK_AND_ASSIGN(serialized_coord_state, + coordinator->ToSerializedState()); + CoordinatorState parsed_awaiting_recovery; + ASSERT_TRUE(parsed_awaiting_recovery.ParseFromString(serialized_coord_state)); + EXPECT_EQ(parsed_awaiting_recovery.status(), + COORDINATOR_STATUS_AWAITING_RECOVERY); + EXPECT_THAT(parsed_awaiting_recovery.responded_decryptor_indices(), + ElementsAre(1, 3, 4)); + + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, + Coordinator::CreateFromSerializedState(serialized_coord_state)); + + // Create indexed recovery requests for survivors {1, 3, 4} to recover dropped + // committee decryptors {2, 5}. + SECAGG_ASSERT_OK_AND_ASSIGN(auto indexed_recovery_requests, + coordinator->CreateIndexedRecoveryRequests()); + ASSERT_EQ(indexed_recovery_requests.size(), 3u); + + std::vector recovery_responses; + for (const auto& [survivor_idx, rec_req] : indexed_recovery_requests) { + EXPECT_EQ(rec_req.indexed_encrypted_shares().size(), 2u); + EXPECT_TRUE(rec_req.indexed_encrypted_shares().contains(2)); + EXPECT_TRUE(rec_req.indexed_encrypted_shares().contains(5)); + + SECAGG_ASSERT_OK_AND_ASSIGN( + RecoveryResponse rec_resp, + committee_decryptors[survivor_idx - 1]->HandleRecoveryRequest(rec_req)); + // One-shot enforcement: a second recovery request on the same survivor must + // fail because its state is now burned to Finished. + EXPECT_FALSE(committee_decryptors[survivor_idx - 1] + ->HandleRecoveryRequest(rec_req) + .ok()); + recovery_responses.push_back(std::move(rec_resp)); + } + + // Coordinator reconstructs dropped decryptors {2, 5}, verifies Step 14 PK + // binding, and accumulates their simulated partial decryptions. + SECAGG_ASSERT_OK(coordinator->RecoverDroppedDecryptors(recovery_responses)); + + // Serialize and reinstantiate Coordinator in OutputReady state. + SECAGG_ASSERT_OK_AND_ASSIGN(serialized_coord_state, + coordinator->ToSerializedState()); + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, + Coordinator::CreateFromSerializedState(serialized_coord_state)); + + SECAGG_ASSERT_OK_AND_ASSIGN(FinalizedPartialDecryption finalized_pd, + coordinator->FinalizePartialDecryption()); + + SECAGG_ASSERT_OK_AND_ASSIGN( + auto final_result_decryptor, + FinalResultDecryptor::CreateFromSerialized( + finalized_accumulator_result.final_result_decryptor_state())); + + PartialDecryptionResponse full_pd_response; + *full_pd_response.mutable_partial_decryption() = + finalized_pd.partial_decryption_sum(); + + SECAGG_ASSERT_OK_AND_ASSIGN( + EncodedData recovered_data, + final_result_decryptor->Decrypt(full_pd_response.SerializeAsString())); + + SECAGG_ASSERT_OK_AND_ASSIGN(DecodedData decoded_results, + encoder->Decode(recovered_data)); + + EXPECT_THAT( + decoded_results.metric_data, + UnorderedElementsAre(Pair("metric1", ElementsAre(96, 61, 39, 40, 98)))); + EXPECT_THAT(decoded_results.group_data, + UnorderedElementsAre( + Pair("country", ElementsAre("CA", "GB", "MX", "US", "US")), + Pair("lang", ElementsAre("es", "en", "en", "en", "es")))); +} + } // namespace } // namespace willow } // namespace secure_aggregation diff --git a/willow/api/coordinator.cc b/willow/api/coordinator.cc index 4c28d1a..c5513ca 100644 --- a/willow/api/coordinator.cc +++ b/willow/api/coordinator.cc @@ -93,6 +93,14 @@ absl::StatusOr Coordinator::ToSerializedState() const { serialized_state.size()); } +absl::Status Coordinator::ConfigureCommittee( + const CommitteeConfigProto& committee_config) { + std::string config_str = committee_config.SerializeAsString(); + SECAGG_RETURN_IF_FFI_ERROR( + coordinator_->ConfigureCommittee(ToRustSlice(config_str))); + return absl::OkStatus(); +} + absl::StatusOr Coordinator::HandleSetupSubmissions( absl::Span non_reputable_contributions, @@ -133,6 +141,70 @@ absl::StatusOr Coordinator::PrepareDecryptionRequest( return dec_request; } +absl::Status Coordinator::AggregatePartialDecryptions( + absl::Span partial_responses) { + SerializedProtoSpan responses_helper( + partial_responses); + SECAGG_RETURN_IF_FFI_ERROR( + coordinator_->AggregatePartialDecryptions(responses_helper.slice)); + return absl::OkStatus(); +} + +absl::StatusOr>> +Coordinator::CreateIndexedRecoveryRequests() const { + rust::Vec raw_requests; + SECAGG_RETURN_IF_FFI_ERROR( + coordinator_->CreateIndexedRecoveryRequests(raw_requests)); + + std::vector> requests; + requests.reserve(raw_requests.size()); + for (const auto& item : raw_requests) { + RecoveryRequest req; + if (!req.ParseFromArray(item.request_bytes.data(), + item.request_bytes.size())) { + return absl::InternalError( + "Failed to parse RecoveryRequest from serialized FFI bytes."); + } + requests.emplace_back(item.survivor_index, std::move(req)); + } + return requests; +} + +absl::StatusOr> +Coordinator::CreateRecoveryRequests() { + SECAGG_ASSIGN_OR_RETURN(auto indexed_requests, + CreateIndexedRecoveryRequests()); + std::vector requests; + requests.reserve(indexed_requests.size()); + for (auto& [survivor_index, req] : indexed_requests) { + requests.push_back(std::move(req)); + } + return requests; +} + +absl::Status Coordinator::RecoverDroppedDecryptors( + absl::Span recovery_responses) { + SerializedProtoSpan responses_helper(recovery_responses); + SECAGG_RETURN_IF_FFI_ERROR( + coordinator_->RecoverDroppedDecryptors(responses_helper.slice)); + return absl::OkStatus(); +} + +absl::StatusOr +Coordinator::FinalizePartialDecryption() { + rust::Vec result_bytes; + SECAGG_RETURN_IF_FFI_ERROR( + coordinator_->FinalizePartialDecryption(result_bytes)); + + FinalizedPartialDecryption finalized_pd; + if (!finalized_pd.ParseFromArray(result_bytes.data(), result_bytes.size())) { + return absl::InternalError( + "Failed to parse FinalizedPartialDecryption from serialized FFI " + "bytes."); + } + return finalized_pd; +} + absl::StatusOr Coordinator::AggregateAndFinalizePartialDecryptions( absl::Span partial_responses) { diff --git a/willow/api/coordinator.h b/willow/api/coordinator.h index 1dfab21..025230a 100644 --- a/willow/api/coordinator.h +++ b/willow/api/coordinator.h @@ -15,10 +15,13 @@ #ifndef SECURE_AGGREGATION_WILLOW_API_COORDINATOR_H_ #define SECURE_AGGREGATION_WILLOW_API_COORDINATOR_H_ +#include #include #include #include +#include +#include "absl/status/status.h" #include "absl/status/statusor.h" #include "absl/strings/string_view.h" #include "absl/types/span.h" @@ -49,6 +52,10 @@ class Coordinator { // CoordinatorState proto. absl::StatusOr ToSerializedState() const; + // Configures the coordinator for a recoverable multi-decryptor committee + // session. Must be called in PreSetup status before HandleSetupSubmissions. + absl::Status ConfigureCommittee(const CommitteeConfigProto& committee_config); + // Stores setup contributions from all decryptors during key generation and // creates a request for reputable decryptors to verify and aggregate key // contributions into a public key. @@ -62,7 +69,38 @@ class Coordinator { absl::StatusOr PrepareDecryptionRequest( const ShellAhePartialDecCiphertext& verifier_ciphertext); - // Accumulates partial decryptions and finalizes the partial decryption sum. + // Accumulates partial decryptions from responding decryptors. + // In recoverable committee mode, transitions the coordinator to OutputReady + // when all committee decryptors respond, or to AwaitingRecovery when at least + // `threshold` (but fewer than `num_decryptors`) respond. + absl::Status AggregatePartialDecryptions( + absl::Span partial_responses); + + // Creates recovery requests keyed by 1-based surviving `decryptor_index` + // (ordered by ascending `responded_decryptor_indices`) to decrypt shares of + // dropped decryptors. Returns an empty vector if the coordinator is already + // in OutputReady status (zero dropouts). + absl::StatusOr>> + CreateIndexedRecoveryRequests() const; + + // Creates recovery requests ordered by ascending surviving `decryptor_index`. + // Returns an empty vector if the coordinator is already in OutputReady status + // (zero dropouts). + absl::StatusOr> CreateRecoveryRequests(); + + // Recovers secret key shares of dropped decryptors from survivor + // RecoveryResponses, verifies regenerated public key shares against setup + // (Willow Paper Fig. 10 Step 14), accumulates their simulated partial + // decryptions, and transitions the coordinator to OutputReady. + absl::Status RecoverDroppedDecryptors( + absl::Span recovery_responses); + + // Finalizes and returns the accumulated partial decryption sum when the + // coordinator is in OutputReady status, transitioning to Finished. + absl::StatusOr FinalizePartialDecryption(); + + // Accumulates partial decryptions and finalizes the partial decryption sum + // in a single step (for the zero-dropout fast path). absl::StatusOr AggregateAndFinalizePartialDecryptions( absl::Span partial_responses); diff --git a/willow/api/coordinator.rs b/willow/api/coordinator.rs index 2c4b0f4..bf1a454 100644 --- a/willow/api/coordinator.rs +++ b/willow/api/coordinator.rs @@ -17,8 +17,8 @@ use aggregation_config_rust_proto::AggregationConfigProto; use ahe_traits::AheBase; use coordinator::Coordinator; use kahe_traits::{HasKahe, KaheBase}; -use messages::CoordinatorState; -use messages_rust_proto::CoordinatorState as CoordinatorStateProto; +use messages::{CommitteeConfig, CoordinatorState, RecoveryResponse}; +use messages_rust_proto::{CommitteeConfigProto, CoordinatorState as CoordinatorStateProto}; use proto_serialization_traits::{FromProto, ToProto}; use protobuf::prelude::*; use shell_ciphertexts_rust_proto::ShellAhePartialDecCiphertext as ShellAhePartialDecCiphertextProto; @@ -38,6 +38,11 @@ pub mod ffi { data: &'a [u8], } + struct IndexedSerializedRecoveryRequest { + survivor_index: u32, + request_bytes: Vec, + } + unsafe extern "C++" { include!("ffi_utils/status.rs.h"); type FfiStatus = status::ffi::FfiStatus; @@ -69,6 +74,12 @@ pub mod ffi { serialized_state: &[u8], ) -> FfiStatus; + #[cxx_name = "ConfigureCommittee"] + fn configure_committee_ffi( + self: &mut WillowShellCoordinator, + serialized_committee_config: &[u8], + ) -> FfiStatus; + #[cxx_name = "HandleSetupSubmissions"] fn handle_setup_submissions_ffi( self: &mut WillowShellCoordinator, @@ -84,6 +95,30 @@ pub mod ffi { out_decryption_request: &mut Vec, ) -> FfiStatus; + #[cxx_name = "AggregatePartialDecryptions"] + fn aggregate_partial_decryptions_ffi( + self: &mut WillowShellCoordinator, + partial_responses: &[SerializedProtoView], + ) -> FfiStatus; + + #[cxx_name = "CreateIndexedRecoveryRequests"] + fn create_indexed_recovery_requests_ffi( + self: &WillowShellCoordinator, + out_requests: &mut Vec, + ) -> FfiStatus; + + #[cxx_name = "RecoverDroppedDecryptors"] + fn recover_dropped_decryptors_ffi( + self: &mut WillowShellCoordinator, + recovery_responses: &[SerializedProtoView], + ) -> FfiStatus; + + #[cxx_name = "FinalizePartialDecryption"] + fn finalize_partial_decryption_ffi( + self: &mut WillowShellCoordinator, + out_finalized_pd: &mut Vec, + ) -> FfiStatus; + #[cxx_name = "AggregateAndFinalizePartialDecryptions"] fn aggregate_and_finalize_partial_decryptions_ffi( self: &mut WillowShellCoordinator, @@ -193,6 +228,21 @@ impl WillowShellCoordinator { self.restore_from_serialized_state(serialized_state).into() } + fn configure_committee( + &mut self, + serialized_committee_config: &[u8], + ) -> Result<(), StatusError> { + let proto = CommitteeConfigProto::parse(serialized_committee_config).map_err(|e| { + status::invalid_argument(&format!("Failed to parse CommitteeConfigProto: {}", e)) + })?; + let committee_config = CommitteeConfig::from_proto(proto, ())?; + self.coord.configure_committee(committee_config, &mut self.coord_state) + } + + fn configure_committee_ffi(&mut self, serialized_committee_config: &[u8]) -> ffi::FfiStatus { + self.configure_committee(serialized_committee_config).into() + } + /// Helper function to parse protobuf views using the coordinator's context. fn parse_views<'a, T>( &'a self, @@ -270,18 +320,77 @@ impl WillowShellCoordinator { .into() } - fn aggregate_and_finalize_partial_decryptions( + fn aggregate_partial_decryptions( &mut self, partial_response_views: &[ffi::SerializedProtoView], - ) -> Result, StatusError> { + ) -> Result<(), StatusError> { let responses = self.parse_views(partial_response_views)?; - self.coord.aggregate_partial_decryptions( responses, Some(self.kahe.as_ref()), &mut self.coord_state, - )?; + ) + } + + fn aggregate_partial_decryptions_ffi( + &mut self, + partial_responses: &[ffi::SerializedProtoView], + ) -> ffi::FfiStatus { + self.aggregate_partial_decryptions(partial_responses).into() + } + + fn create_indexed_recovery_requests( + &self, + ) -> Result, StatusError> { + let indexed = self.coord.create_indexed_recovery_requests(&self.coord_state)?; + let mut out = Vec::with_capacity(indexed.len()); + for (survivor_idx, req) in indexed { + let proto = req.to_proto(())?; + let request_bytes = proto.serialize().map_err(|e| { + status::internal(&format!("Failed to serialize RecoveryRequest: {}", e)) + })?; + out.push(ffi::IndexedSerializedRecoveryRequest { + survivor_index: survivor_idx.get(), + request_bytes, + }); + } + Ok(out) + } + fn create_indexed_recovery_requests_ffi( + &self, + out_requests: &mut Vec, + ) -> ffi::FfiStatus { + self.create_indexed_recovery_requests() + .map(|requests| { + *out_requests = requests; + }) + .into() + } + + fn recover_dropped_decryptors( + &mut self, + recovery_response_views: &[ffi::SerializedProtoView], + ) -> Result<(), StatusError> { + let mut responses = Vec::with_capacity(recovery_response_views.len()); + for view in recovery_response_views { + let proto = + >::Proto::parse(view.data).map_err(|e| { + status::invalid_argument(&format!("Failed to parse RecoveryResponse: {}", e)) + })?; + responses.push(RecoveryResponse::from_proto(proto, ())?); + } + self.coord.recover_dropped_decryptors(responses, &mut self.coord_state) + } + + fn recover_dropped_decryptors_ffi( + &mut self, + recovery_responses: &[ffi::SerializedProtoView], + ) -> ffi::FfiStatus { + self.recover_dropped_decryptors(recovery_responses).into() + } + + fn finalize_partial_decryption(&mut self) -> Result, StatusError> { let finalized_pd = self.coord.finalize_partial_decryption(&mut self.coord_state)?; let proto = finalized_pd.to_proto(&*self)?; proto.serialize().map_err(|e| { @@ -289,6 +398,25 @@ impl WillowShellCoordinator { }) } + fn finalize_partial_decryption_ffi( + &mut self, + out_finalized_pd: &mut Vec, + ) -> ffi::FfiStatus { + self.finalize_partial_decryption() + .map(|serialized| { + *out_finalized_pd = serialized; + }) + .into() + } + + fn aggregate_and_finalize_partial_decryptions( + &mut self, + partial_response_views: &[ffi::SerializedProtoView], + ) -> Result, StatusError> { + self.aggregate_partial_decryptions(partial_response_views)?; + self.finalize_partial_decryption() + } + fn aggregate_and_finalize_partial_decryptions_ffi( &mut self, partial_responses: &[ffi::SerializedProtoView], diff --git a/willow/api/coordinator_test.cc b/willow/api/coordinator_test.cc index e798e6f..b4a3626 100644 --- a/willow/api/coordinator_test.cc +++ b/willow/api/coordinator_test.cc @@ -172,6 +172,108 @@ TEST(CoordinatorTest, AggregateAndFinalizeFailsWhenNotInCorrectState) { EXPECT_FALSE(finalize_or.ok()); } +TEST(CoordinatorTest, + ConfigureCommitteeAndRecoverableSetupPreservesStateAcrossSerialization) { + AggregationConfigProto config = CreateValidConfig(); + SECAGG_ASSERT_OK_AND_ASSIGN(auto coordinator, Coordinator::Create(config)); + + constexpr uint32_t kNumDecryptors = 3; + constexpr uint32_t kThreshold = 2; + constexpr absl::string_view kSessionContext = "coordinator_test_session"; + + std::vector> decryptors; + std::vector roster_pks; + for (uint32_t i = 1; i <= kNumDecryptors; ++i) { + SECAGG_ASSERT_OK_AND_ASSIGN(auto dec, + ShellTestingDecryptor::Create(config)); + SECAGG_ASSERT_OK_AND_ASSIGN(std::string pk, dec->GenerateHpkePublicKey()); + roster_pks.push_back(std::move(pk)); + decryptors.push_back(std::move(dec)); + } + + SECAGG_ASSERT_OK_AND_ASSIGN(CommitteeConfigProto coord_committee_cfg, + ShellTestingDecryptor::BuildCommitteeConfig( + /*my_index=*/1, kNumDecryptors, kThreshold, + kSessionContext, roster_pks)); + SECAGG_ASSERT_OK(coordinator->ConfigureCommittee(coord_committee_cfg)); + + // Serialize and reinstantiate in PreSetup state with committee_config. + SECAGG_ASSERT_OK_AND_ASSIGN(std::string pre_setup_state, + coordinator->ToSerializedState()); + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, Coordinator::CreateFromSerializedState(pre_setup_state)); + + std::vector non_reputable; + for (uint32_t i = 1; i <= kNumDecryptors; ++i) { + SECAGG_ASSERT_OK_AND_ASSIGN( + CommitteeConfigProto dec_cfg, + ShellTestingDecryptor::BuildCommitteeConfig( + i, kNumDecryptors, kThreshold, kSessionContext, roster_pks)); + SECAGG_ASSERT_OK_AND_ASSIGN( + SetupContribution contrib, + decryptors[i - 1]->CreateRecoverableSetupContribution(dec_cfg, + roster_pks)); + non_reputable.push_back(std::move(contrib)); + } + + // Empty reputable contributions must be rejected in recoverable committee + // mode. + EXPECT_THAT( + coordinator->HandleSetupSubmissions(non_reputable, /*reputable=*/{}), + StatusIs(absl::StatusCode::kInvalidArgument, + HasSubstr("At least one reputable_contribution is required"))); + + SECAGG_ASSERT_OK_AND_ASSIGN(auto rep_decryptor, + ShellTestingDecryptor::Create(config)); + SECAGG_ASSERT_OK_AND_ASSIGN(SetupContribution rep_contrib, + rep_decryptor->CreateSetupContribution()); + std::vector reputable = {std::move(rep_contrib)}; + SECAGG_ASSERT_OK_AND_ASSIGN( + auto verify_request, + coordinator->HandleSetupSubmissions(non_reputable, reputable)); + EXPECT_THAT(verify_request.key_contributions(), SizeIs(kNumDecryptors + 1)); + + // ConfigureCommittee must fail once past PreSetup. + EXPECT_THAT( + coordinator->ConfigureCommittee(coord_committee_cfg), + StatusIs(absl::StatusCode::kFailedPrecondition, HasSubstr("PreSetup"))); + + // CreateRecoveryRequests, RecoverDroppedDecryptors, and + // FinalizePartialDecryption must fail in KeySharesReceived state. + EXPECT_THAT(coordinator->CreateRecoveryRequests(), + StatusIs(absl::StatusCode::kFailedPrecondition)); + EXPECT_THAT(coordinator->RecoverDroppedDecryptors({}), + StatusIs(absl::StatusCode::kFailedPrecondition)); + EXPECT_THAT(coordinator->FinalizePartialDecryption(), + StatusIs(absl::StatusCode::kFailedPrecondition)); + + // Serialize and reinstantiate in KeySharesReceived state with + // dealer_setup_info and setup_transcript_hash. + SECAGG_ASSERT_OK_AND_ASSIGN(std::string post_setup_state, + coordinator->ToSerializedState()); + SECAGG_ASSERT_OK_AND_ASSIGN( + coordinator, Coordinator::CreateFromSerializedState(post_setup_state)); + SECAGG_ASSERT_OK_AND_ASSIGN(std::string roundtrip_post_setup_state, + coordinator->ToSerializedState()); + + CoordinatorState orig_state; + ASSERT_TRUE(orig_state.ParseFromString(post_setup_state)); + CoordinatorState parsed_state; + ASSERT_TRUE(parsed_state.ParseFromString(roundtrip_post_setup_state)); + EXPECT_EQ(parsed_state.status(), COORDINATOR_STATUS_KEY_SHARES_RECEIVED); + EXPECT_EQ(parsed_state.dealer_setup_info().size(), kNumDecryptors); + EXPECT_EQ(parsed_state.setup_transcript_hash().size(), 32); + EXPECT_EQ(parsed_state.setup_transcript_hash(), + orig_state.setup_transcript_hash()); + for (uint32_t i = 1; i <= kNumDecryptors; ++i) { + ASSERT_TRUE(parsed_state.dealer_setup_info().contains(i)); + ASSERT_TRUE(orig_state.dealer_setup_info().contains(i)); + EXPECT_EQ(parsed_state.dealer_setup_info().at(i).dealer_index(), i); + EXPECT_EQ(parsed_state.dealer_setup_info().at(i).encrypted_shares().size(), + kNumDecryptors - 1); + } +} + } // namespace } // namespace willow } // namespace secure_aggregation diff --git a/willow/api/server_accumulator.rs b/willow/api/server_accumulator.rs index 4ec6e0b..8e393d1 100644 --- a/willow/api/server_accumulator.rs +++ b/willow/api/server_accumulator.rs @@ -536,7 +536,7 @@ pub struct FinalResultDecryptor { /// /// NOTE: We technically only need client_sum, not decryptor_public_key_shares or /// partial_decryption_sum, but because of the monolithic SecureAggregationServer trait - /// (b/476137863) we need a complete CiphertextAccumulatorState to call the decryption + /// (b/476380769) we need a complete CiphertextAccumulatorState to call the decryption /// functions. accumulator_state: CiphertextAccumulatorState, diff --git a/willow/crypto/BUILD b/willow/crypto/BUILD index 42f5632..ec024b6 100644 --- a/willow/crypto/BUILD +++ b/willow/crypto/BUILD @@ -193,6 +193,16 @@ rust_library( ], ) +rust_test( + name = "shell_parameters_test", + crate = ":shell_parameters", + deps = [ + "@crate_index//:googletest", + "//ffi_utils:status", + "//ffi_utils:status_matchers_rs", + ], +) + # Utilities for parameters proto (e.g. conversion between rust structs and protos) rust_library( name = "shell_parameters_utils", @@ -300,6 +310,56 @@ rust_test( ], ) +# Feldman Verifiable Secret Sharing (VSS) +rust_library( + name = "feldman_vss", + srcs = ["feldman_vss.rs"], + deps = [ + ":prng_traits", + ":single_thread_hkdf", + "@protobuf//rust:protobuf", + "@crate_index//:curve25519-dalek", + "//ffi_utils:status", + "//willow/api:proto_serialization_traits", + "//willow/proto/willow:messages_rust_proto", + ], +) + +rust_test( + name = "feldman_vss_test", + crate = ":feldman_vss", + deps = [ + ":single_thread_hkdf", + "@crate_index//:googletest", + "//ffi_utils:status_matchers_rs", + ], +) + +# Peer share encryption (Tink HPKE) +rust_library( + name = "share_cipher", + srcs = ["share_cipher.rs"], + deps = [ + ":feldman_vss", + "@crate_index//:curve25519-dalek", + "//ffi_utils:status", + "//shell_wrapper:hpke", + "//shell_wrapper:hpke_cxx", + ], +) + +rust_test( + name = "share_cipher_test", + crate = ":share_cipher", + deps = [ + ":feldman_vss", + ":prng_traits", + ":single_thread_hkdf", + "@crate_index//:googletest", + "//ffi_utils:status_matchers_rs", + ], +) + # Zero knowledge rust_library( diff --git a/willow/crypto/ahe_traits.rs b/willow/crypto/ahe_traits.rs index fe19b22..6f44c4f 100644 --- a/willow/crypto/ahe_traits.rs +++ b/willow/crypto/ahe_traits.rs @@ -28,7 +28,7 @@ pub trait AheBase: Sized { type SecretKeyShare; /// Public key share. - type PublicKeyShare: Clone; + type PublicKeyShare: Clone + PartialEq + Eq; type KeyGenMetadata; diff --git a/willow/crypto/feldman_vss.rs b/willow/crypto/feldman_vss.rs new file mode 100644 index 0000000..6a058b4 --- /dev/null +++ b/willow/crypto/feldman_vss.rs @@ -0,0 +1,864 @@ +// Copyright 2026 Google LLC +// +// 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. + +//! Minimal $t$-of-$n$ Feldman Verifiable Secret Sharing (VSS) over Ristretto255. +//! +//! In Willow's multi-decryptor protocol (ePrint 2024/936, Figure 10 & Appendix E.1), +//! each non-reputable decryptor samples a high-entropy seed scalar $s \in \mathbb{F}_\ell$, +//! derives its RLWE key-generation PRNG seed from $s$, and shares $s$ among the +//! decryptor committee using Feldman VSS over the prime-order Ristretto255 group. +//! +//! ## References +//! - Paul Feldman, *"A Practical Scheme for Non-Interactive Verifiable Secret Sharing"*, +//! Proceedings of the 28th Annual Symposium on Foundations of Computer Science (FOCS), pp. +//! 427–437, IEEE, 1987 (`doi:10.1109/SFCS.1987.4`). +//! - Adi Shamir, *"How to Share a Secret"*, Communications of the ACM, 22(11):612–613, 1979. + +use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint}; +use curve25519_dalek::scalar::Scalar; +use curve25519_dalek::traits::{IsIdentity, VartimeMultiscalarMul}; +use messages_rust_proto::{FeldmanCommitmentProto, FeldmanShareProto}; +use prng_traits::SecurePrng; +use proto_serialization_traits::{FromProto, ToProto}; +use protobuf::{proto, AsView}; +use status::StatusError; +use std::collections::BTreeSet; +use std::num::NonZeroU32; + +/// Maximum number of shares (and maximum threshold) supported by Feldman VSS. +/// Aligned with Willow's `MAX_NUM_DECRYPTORS = 100` ceiling in `shell_parameters.rs`. +pub const MAX_SHARES: u32 = 100; + +/// Canonical byte length of a Ristretto255 scalar ($\mathbb{F}_\ell$). +pub const SCALAR_BYTE_LEN: usize = 32; + +/// Canonical byte length of a compressed Ristretto255 point. +pub const COMMITMENT_POINT_BYTE_LEN: usize = 32; + +/// Canonical byte length of a SHA-256 committee roster hash. +pub const ROSTER_HASH_BYTE_LEN: usize = 32; + +/// Domain-separation prefix for deriving a 64-byte RLWE keygen PRNG seed from a 32-byte +/// Feldman VSS `SecretScalar`. +pub const KEYGEN_SEED_HKDF_INFO_PREFIX: &[u8] = b"Decryptor.keygen_seed"; + +/// A secret Ristretto255 scalar $\in \mathbb{F}_\ell$. +/// +/// Note on computational hiding: Feldman VSS publishes $C_0 = s \cdot G$ without a +/// blinding factor. Callers must only share high-entropy secrets sampled via +/// [`SecretScalar::random`]. +#[derive(Clone)] +pub struct SecretScalar(Scalar); + +impl PartialEq for SecretScalar { + fn eq(&self, other: &Self) -> bool { + // `Scalar::eq` in `curve25519-dalek` delegates to `subtle::ConstantTimeEq::ct_eq`. + self.0 == other.0 + } +} + +impl Eq for SecretScalar {} + +impl std::fmt::Debug for SecretScalar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretScalar([REDACTED])") + } +} + +impl SecretScalar { + /// Wraps an existing `Scalar` in a `SecretScalar`. + pub fn new(scalar: Scalar) -> Self { + Self(scalar) + } + + /// Samples a uniform non-zero `SecretScalar` using wide 64-byte modular reduction + /// (`Scalar::from_bytes_mod_order_wide`, bias $< 2^{-259}$). + pub fn random(prng: &mut P) -> Result { + let mut wide_bytes = [0u8; 64]; + loop { + for b in wide_bytes.iter_mut() { + *b = prng.rand8()?; + } + let scalar = Scalar::from_bytes_mod_order_wide(&wide_bytes); + if scalar != Scalar::ZERO { + return Ok(Self(scalar)); + } + } + } + + /// Deserializes a `SecretScalar` from its canonical 32-byte little-endian encoding, + /// rejecting non-canonical encodings ($\ge \ell$). + pub fn from_canonical_bytes(bytes: &[u8]) -> Result { + let arr: [u8; SCALAR_BYTE_LEN] = bytes.try_into().map_err(|_| { + status::invalid_argument("SecretScalar byte slice must be exactly 32 bytes") + })?; + let opt: Option = Scalar::from_canonical_bytes(arr).into(); + opt.map(Self).ok_or_else(|| { + status::invalid_argument("Non-canonical Ristretto255 scalar encoding (>= group order)") + }) + } + + /// Returns the canonical 32-byte little-endian encoding of this scalar. + pub fn to_bytes(&self) -> [u8; SCALAR_BYTE_LEN] { + self.0.to_bytes() + } + + /// Returns a reference to the canonical 32-byte little-endian encoding of this scalar. + pub fn as_bytes(&self) -> &[u8; SCALAR_BYTE_LEN] { + self.0.as_bytes() + } + + /// Returns a reference to the underlying `Scalar`. + pub fn as_scalar(&self) -> &Scalar { + &self.0 + } +} + +/// A single Shamir/Feldman share $(i, f(i))$ evaluated at a 1-based index $i \in \{1, \dots, n\}$. +#[derive(Clone, PartialEq, Eq)] +pub struct Share { + pub index: NonZeroU32, + pub value: SecretScalar, +} + +impl std::fmt::Debug for Share { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Share").field("index", &self.index).field("value", &"[REDACTED]").finish() + } +} + +/// Public Feldman polynomial commitment $(C_0, C_1, \dots, C_{t-1})$ where $C_k = a_k \cdot G$. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FeldmanCommitment { + commitments: Vec, +} + +impl FeldmanCommitment { + /// Constructs a `FeldmanCommitment` after verifying that `commitments` has length in + /// `1..=MAX_SHARES` and every compressed point is a valid non-identity Ristretto255 point. + pub fn new(commitments: Vec) -> Result { + if commitments.is_empty() || commitments.len() > MAX_SHARES as usize { + return Err(status::invalid_argument( + "FeldmanCommitment length must be in 1..=MAX_SHARES", + )); + } + for c in &commitments { + let pt = c.decompress().ok_or_else(|| { + status::invalid_argument("Invalid compressed Ristretto255 point in commitment") + })?; + if pt.is_identity() { + return Err(status::invalid_argument( + "FeldmanCommitment must not contain the identity point", + )); + } + } + Ok(Self { commitments }) + } + + /// Returns the reconstruction threshold $t$ (number of coefficient commitments). + pub fn threshold(&self) -> usize { + self.commitments.len() + } + + /// Returns the slice of compressed Ristretto255 coefficient commitments $[C_0, \dots, + /// C_{t-1}]$. + pub fn commitments(&self) -> &[CompressedRistretto] { + &self.commitments + } +} + +impl ToProto for Share { + type Proto = FeldmanShareProto; + + fn to_proto(&self, _ctx: ()) -> Result { + if self.index.get() > MAX_SHARES { + return Err(status::invalid_argument("Share index must be in 1..=MAX_SHARES")); + } + Ok(proto!(FeldmanShareProto { + index: self.index.get(), + value: self.value.as_bytes().to_vec(), + })) + } +} + +impl FromProto for Share { + type Proto = FeldmanShareProto; + + fn from_proto( + proto: impl AsView, + _ctx: (), + ) -> Result { + let proto = proto.as_view(); + let raw_index = proto.index(); + if raw_index == 0 || raw_index > MAX_SHARES { + return Err(status::invalid_argument( + "FeldmanShareProto index must be in 1..=MAX_SHARES", + )); + } + let index = NonZeroU32::new(raw_index) + .ok_or_else(|| status::invalid_argument("FeldmanShareProto index must be non-zero"))?; + let value = SecretScalar::from_canonical_bytes(proto.value())?; + if *value.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument( + "FeldmanShareProto scalar value must be non-zero", + )); + } + Ok(Self { index, value }) + } +} + +impl ToProto for FeldmanCommitment { + type Proto = FeldmanCommitmentProto; + + fn to_proto(&self, _ctx: ()) -> Result { + if self.commitments.is_empty() || self.commitments.len() > MAX_SHARES as usize { + return Err(status::invalid_argument( + "FeldmanCommitment length must be in 1..=MAX_SHARES", + )); + } + Ok(proto!(FeldmanCommitmentProto { + commitments: self.commitments.iter().map(|c| c.as_bytes().to_vec()), + })) + } +} + +impl FromProto for FeldmanCommitment { + type Proto = FeldmanCommitmentProto; + + fn from_proto( + proto: impl AsView, + _ctx: (), + ) -> Result { + let proto = proto.as_view(); + let raw_commitments = proto.commitments(); + if raw_commitments.is_empty() || raw_commitments.len() > MAX_SHARES as usize { + return Err(status::invalid_argument( + "FeldmanCommitmentProto must have 1..=MAX_SHARES commitments", + )); + } + let mut commitments = Vec::with_capacity(raw_commitments.len()); + for bytes in raw_commitments.iter() { + let arr: [u8; COMMITMENT_POINT_BYTE_LEN] = bytes.try_into().map_err(|_| { + status::invalid_argument("FeldmanCommitmentProto point must be exactly 32 bytes") + })?; + commitments.push(CompressedRistretto(arr)); + } + Self::new(commitments) + } +} + +/// Splits `secret` into `num_shares` shares with reconstruction threshold `threshold` ($t$-of-$n$) +/// evaluated at 1-based indices $1, 2, \dots, \text{num\_shares}$ following Shamir (1979) and +/// Feldman (1987, FOCS, pp. 427–437). +/// +/// Validates `1 <= threshold <= num_shares <= MAX_SHARES` and `*secret.as_scalar() != +/// Scalar::ZERO`. +pub fn split( + secret: &SecretScalar, + threshold: u32, + num_shares: u32, + prng: &mut P, +) -> Result<(FeldmanCommitment, Vec), StatusError> { + if threshold == 0 || threshold > num_shares || num_shares > MAX_SHARES { + return Err(status::invalid_argument( + "Invalid (threshold, num_shares): require 1 <= threshold <= num_shares <= MAX_SHARES", + )); + } + if *secret.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument("Secret scalar for Feldman VSS must be non-zero")); + } + + let t = threshold as usize; + let n = num_shares as usize; + + let mut coeffs = Vec::with_capacity(t); + coeffs.push(*secret.as_scalar()); + for _ in 1..t { + let c = SecretScalar::random(prng)?; + coeffs.push(*c.as_scalar()); + } + + // Constant-time fixed-base scalar multiplication for all secret polynomial coefficients. + let commitments: Vec = + coeffs.iter().map(|a_k| RistrettoPoint::mul_base(a_k).compress()).collect(); + + // Evaluate f(x) = sum_{k=0}^{t-1} a_k * x^k via Horner's method for x = 1..=num_shares. + let mut shares = Vec::with_capacity(n); + for i in 1..=num_shares { + let index = NonZeroU32::new(i) + .ok_or_else(|| status::internal("Share index in 1..=num_shares must be non-zero"))?; + let x = Scalar::from(i); + let mut y = Scalar::ZERO; + for coeff in coeffs.iter().rev() { + y = y * x + coeff; + } + shares.push(Share { index, value: SecretScalar::new(y) }); + } + + Ok((FeldmanCommitment { commitments }, shares)) +} + +/// Verifies a single `share` against `commitment` by checking Feldman's (1987) verification +/// equation $y_i \cdot G \stackrel{?}{=} \sum_{k=0}^{t-1} x_i^k \cdot C_k$ in constant time. +/// +/// Although [`split`] generates shares sequentially at $1 \dots \text{num\_shares}$, +/// `verify_share` accepts untrusted [`Share`]s deserialized from network messages +/// (`RecoveryResponse` / `FeldmanShareProto`) or decrypted from peer HPKE ciphertexts during +/// dropout recovery, and therefore explicitly validates `share.index.get() <= MAX_SHARES` and +/// `*share.value.as_scalar() != Scalar::ZERO`. +pub fn verify_share(commitment: &FeldmanCommitment, share: &Share) -> status::Status { + let t = commitment.commitments.len(); + if t == 0 || t > MAX_SHARES as usize { + return Err(status::invalid_argument( + "FeldmanCommitment threshold out of valid range 1..=MAX_SHARES", + )); + } + if share.index.get() > MAX_SHARES { + return Err(status::invalid_argument("Share index exceeds MAX_SHARES")); + } + if *share.value.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument("Feldman VSS share value must be non-zero")); + } + + let mut points = Vec::with_capacity(t); + for c in &commitment.commitments { + let pt = c.decompress().ok_or_else(|| { + status::invalid_argument("Invalid compressed Ristretto255 point in FeldmanCommitment") + })?; + if pt.is_identity() { + return Err(status::invalid_argument("Identity point in FeldmanCommitment")); + } + points.push(pt); + } + + // Compute powers 1, x, x^2, ..., x^{t-1} directly in F_l to prevent u32/u64 overflow. + let x = Scalar::from(share.index.get()); + let mut powers = Vec::with_capacity(t); + let mut x_pow = Scalar::ONE; + for _ in 0..t { + powers.push(x_pow); + x_pow *= x; + } + + // Constant-time fixed-base multiplication on the secret share scalar `share.value`. + let lhs = RistrettoPoint::mul_base(share.value.as_scalar()); + // Public-input multiscalar multiplication for the right-hand side. + let rhs = RistrettoPoint::vartime_multiscalar_mul(powers.iter(), points.iter()); + + // `RistrettoPoint::eq` uses `subtle::ConstantTimeEq::ct_eq` internally. + if lhs == rhs { + Ok(()) + } else { + Err(status::invalid_argument("Feldman VSS share verification failed")) + } +} + +/// Reconstructs the shared `SecretScalar` $s = f(0)$ from `shares` via Shamir (1979) Lagrange +/// interpolation in $\mathbb{F}_\ell$ after verifying every passed share against `commitment` +/// via [`verify_share`] (Feldman 1987). +/// +/// Fails fast with `INVALID_ARGUMENT` if `shares.len() < commitment.threshold()`, +/// `shares.len() > MAX_SHARES`, any two shares have the same `index`, or any share fails +/// [`verify_share`]. +/// +/// Note on index validation: although [`split`] evaluates shares at distinct sequential indices +/// $1 \dots \text{num\_shares}$, `reconstruct` operates on untrusted [`Share`]s collected from +/// network `RecoveryResponse` messages (`FeldmanShareProto`) during dropout recovery. Validating +/// `1..=MAX_SHARES` and pairwise-distinct evaluation indices is required because +/// `Scalar::batch_invert` (`curve25519_dalek/v4/src/scalar.rs:760-764`) requires callers to +/// guarantee that all input scalars are non-zero (`xs[m] - xs[j] != 0`), panicking in debug +/// builds and silently returning zero in release builds if any denominator is zero. +pub fn reconstruct( + commitment: &FeldmanCommitment, + shares: &[Share], +) -> Result { + let t = commitment.threshold(); + if t == 0 || t > MAX_SHARES as usize { + return Err(status::invalid_argument( + "FeldmanCommitment threshold out of valid range 1..=MAX_SHARES", + )); + } + if shares.len() < t { + return Err(status::invalid_argument("Insufficient shares to reconstruct secret")); + } + if shares.len() > MAX_SHARES as usize { + return Err(status::invalid_argument("Number of shares exceeds MAX_SHARES")); + } + + let mut seen_indices = BTreeSet::new(); + for share in shares { + if !seen_indices.insert(share.index) { + return Err(status::invalid_argument("Duplicate share index in reconstruct")); + } + verify_share(commitment, share)?; + } + + let active_shares = &shares[..t]; + let xs: Vec = active_shares.iter().map(|s| Scalar::from(s.index.get())).collect(); + + let mut numerators = Vec::with_capacity(t); + let mut denominators = Vec::with_capacity(t); + for j in 0..t { + let mut num = Scalar::ONE; + let mut den = Scalar::ONE; + for m in 0..t { + if m != j { + num *= xs[m]; + den *= xs[m] - xs[j]; + } + } + // Guard against zero denominators before calling `Scalar::batch_invert`, which panics + // in debug builds and silently returns zero in release builds if any element is zero. + if den == Scalar::ZERO { + return Err(status::invalid_argument("Zero denominator in Lagrange interpolation")); + } + numerators.push(num); + denominators.push(den); + } + + Scalar::batch_invert(&mut denominators); + + let mut secret = Scalar::ZERO; + for (j, share) in active_shares.iter().enumerate() { + let lambda_j = numerators[j] * denominators[j]; + secret += share.value.as_scalar() * lambda_j; + } + + Ok(SecretScalar::new(secret)) +} + +/// Expands a 32-byte `SecretScalar` into a `single_thread_hkdf::seed_length()` (64-byte) +/// `single_thread_hkdf::Seed` via HKDF-SHA256 (`single_thread_hkdf::compute_hkdf`), binding: +/// - `KEYGEN_SEED_HKDF_INFO_PREFIX` (`b"Decryptor.keygen_seed"`), +/// - length-prefixed `session_context` (`u32_le(session_context.len()) || session_context`), +/// - `roster_hash` (32 bytes), and +/// - `dealer_index` (`u32_le(dealer_index)` in `1..=MAX_SHARES`). +pub fn derive_keygen_seed( + vss_secret: &SecretScalar, + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, +) -> Result { + if *vss_secret.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument("derive_keygen_seed: vss_secret must be non-zero")); + } + if dealer_index.get() > MAX_SHARES { + return Err(status::invalid_argument( + "derive_keygen_seed: dealer_index must be in 1..=MAX_SHARES", + )); + } + let session_context_len = u32::try_from(session_context.len()).map_err(|_| { + status::invalid_argument("derive_keygen_seed: session_context length exceeds u32::MAX") + })?; + + // Bind length-prefixed `session_context` in HKDF `info` (as well as `salt`) so that + // `session_context` values differing only by trailing zero bytes (< 64B HMAC block padding) + // still produce strictly distinct HKDF outputs. + let mut info = Vec::with_capacity( + KEYGEN_SEED_HKDF_INFO_PREFIX.len() + 4 + session_context.len() + ROSTER_HASH_BYTE_LEN + 4, + ); + info.extend_from_slice(KEYGEN_SEED_HKDF_INFO_PREFIX); + info.extend_from_slice(&session_context_len.to_le_bytes()); + info.extend_from_slice(session_context); + info.extend_from_slice(roster_hash); + info.extend_from_slice(&dealer_index.get().to_le_bytes()); + + let secret_bytes = vss_secret.to_bytes(); + let out_len = single_thread_hkdf::seed_length(); + single_thread_hkdf::compute_hkdf(&secret_bytes, session_context, &info, out_len) +} + +#[cfg(test)] +mod tests { + use super::*; + use googletest::{gtest, verify_eq, verify_that, Result as GTestResult}; + use single_thread_hkdf::SingleThreadHkdfPrng; + use status::StatusErrorCode; + use status_matchers_rs::status_is; + + fn create_test_prng() -> Result { + let seed = SingleThreadHkdfPrng::generate_seed()?; + SingleThreadHkdfPrng::create(&seed) + } + + #[gtest] + fn test_split_verify_and_reconstruct_roundtrip() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + + let (commitment, shares) = split(&secret, 3, 5, &mut prng)?; + verify_eq!(commitment.threshold(), 3)?; + verify_eq!(shares.len(), 5)?; + + for share in &shares { + verify_share(&commitment, share)?; + } + + // Any 3-share subset reconstructs the exact secret. + let subset_a = vec![shares[0].clone(), shares[1].clone(), shares[2].clone()]; + let subset_b = vec![shares[1].clone(), shares[3].clone(), shares[4].clone()]; + let subset_c = vec![shares[4].clone(), shares[2].clone(), shares[0].clone()]; + verify_eq!(reconstruct(&commitment, &subset_a)?, secret)?; + verify_eq!(reconstruct(&commitment, &subset_b)?, secret)?; + verify_eq!(reconstruct(&commitment, &subset_c)?, secret)?; + + // Passing > t valid shares also reconstructs the exact secret. + verify_eq!(reconstruct(&commitment, &shares)?, secret)?; + Ok(()) + } + + #[gtest] + fn test_boundary_thresholds_t1_t_eq_n_and_max_shares() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + + // t = 1, n = 1 + let (comm_1_1, shares_1_1) = split(&secret, 1, 1, &mut prng)?; + verify_eq!(reconstruct(&comm_1_1, &shares_1_1)?, secret)?; + + // t = 1, n = 5 + let (comm_1_5, shares_1_5) = split(&secret, 1, 5, &mut prng)?; + verify_eq!(reconstruct(&comm_1_5, &[shares_1_5[4].clone()])?, secret)?; + + // t = n = 5 + let (comm_5_5, shares_5_5) = split(&secret, 5, 5, &mut prng)?; + verify_eq!(reconstruct(&comm_5_5, &shares_5_5)?, secret)?; + + // High degree & index x = 100 (verifies no u64 overflow when x^k > 2^64). + let (comm_15_100, shares_15_100) = split(&secret, 15, MAX_SHARES, &mut prng)?; + verify_share(&comm_15_100, &shares_15_100[99])?; + let high_index_subset: Vec = shares_15_100[85..100].to_vec(); + verify_eq!(reconstruct(&comm_15_100, &high_index_subset)?, secret)?; + + Ok(()) + } + + #[gtest] + fn test_invalid_split_parameters_rejected() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + + verify_that!( + split(&secret, 0, 5, &mut prng), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + split(&secret, 6, 5, &mut prng), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + split(&secret, 3, MAX_SHARES + 1, &mut prng), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let zero_secret = SecretScalar::new(Scalar::ZERO); + verify_that!( + split(&zero_secret, 2, 3, &mut prng), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + Ok(()) + } + + #[gtest] + fn test_tampered_share_and_wrong_commitment_rejected() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + let (commitment, shares) = split(&secret, 3, 5, &mut prng)?; + + // Tamper with share value. + let mut bad_share = shares[0].clone(); + bad_share.value = SecretScalar::new(*bad_share.value.as_scalar() + Scalar::ONE); + verify_that!( + verify_share(&commitment, &bad_share), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Even if bad_share is the (t+1)-th share passed to reconstruct, reconstruct must fail + // fast. + let mut shares_with_bad_tail = + vec![shares[0].clone(), shares[1].clone(), shares[2].clone(), bad_share]; + verify_that!( + reconstruct(&commitment, &shares_with_bad_tail), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + shares_with_bad_tail.pop(); + + // Share from a different polynomial is rejected. + let other_secret = SecretScalar::random(&mut prng)?; + let (other_commitment, _) = split(&other_secret, 3, 5, &mut prng)?; + verify_that!( + verify_share(&other_commitment, &shares[0]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Out-of-range share index (> MAX_SHARES) is rejected. + let out_of_range_share = Share { + index: NonZeroU32::new(MAX_SHARES + 1).unwrap(), + value: shares[0].value.clone(), + }; + verify_that!( + verify_share(&commitment, &out_of_range_share), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_reconstruct_rejects_fewer_than_t_and_duplicate_indices() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + let (commitment, shares) = split(&secret, 3, 5, &mut prng)?; + + // Fewer than t shares. + verify_that!( + reconstruct(&commitment, &shares[..2]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Duplicate share indices (guards against batch_invert zero denominator). + let duplicate_shares = vec![shares[0].clone(), shares[0].clone(), shares[1].clone()]; + verify_that!( + reconstruct(&commitment, &duplicate_shares), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_canonical_scalar_and_debug_redaction() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + let roundtrip = SecretScalar::from_canonical_bytes(&secret.to_bytes())?; + verify_eq!(secret, roundtrip)?; + + // Non-canonical scalar bytes (0xFF..FF >= group order l) must be rejected. + let non_canonical = [0xFFu8; SCALAR_BYTE_LEN]; + verify_that!( + SecretScalar::from_canonical_bytes(&non_canonical), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Wrong slice length must be rejected. + verify_that!( + SecretScalar::from_canonical_bytes(&[1u8; 31]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Debug output must redact secret scalar values. + let share = Share { index: NonZeroU32::new(1).unwrap(), value: secret }; + let debug_str = format!("{:?} {:?}", share.value, share); + verify_eq!(debug_str.contains("REDACTED"), true)?; + + Ok(()) + } + + #[gtest] + fn test_commitment_validation_rejects_empty_noncanonical_and_identity() -> GTestResult<()> { + // Empty commitment vector. + verify_that!(FeldmanCommitment::new(vec![]), status_is(StatusErrorCode::INVALID_ARGUMENT))?; + + // Commitment vector exceeding MAX_SHARES. + let valid_point = RistrettoPoint::mul_base(&Scalar::ONE).compress(); + verify_that!( + FeldmanCommitment::new(vec![valid_point; (MAX_SHARES + 1) as usize]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Identity point must be rejected. + verify_that!( + FeldmanCommitment::new(vec![CompressedRistretto::default()]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Non-canonical / invalid compressed Ristretto255 point must be rejected. + verify_that!( + FeldmanCommitment::new(vec![CompressedRistretto([0xFFu8; 32])]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_proto_serialization_roundtrip_and_malformed_inputs() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret = SecretScalar::random(&mut prng)?; + let (commitment, shares) = split(&secret, 3, 5, &mut prng)?; + + // Valid round-trip for FeldmanCommitment and Share. + let comm_proto = commitment.to_proto(())?; + let comm_roundtrip = FeldmanCommitment::from_proto(comm_proto, ())?; + verify_eq!(comm_roundtrip, commitment)?; + + let mut shares_roundtrip = Vec::with_capacity(shares.len()); + for share in &shares { + let share_proto = share.to_proto(())?; + let decoded = Share::from_proto(share_proto, ())?; + verify_eq!(&decoded, share)?; + verify_share(&comm_roundtrip, &decoded)?; + shares_roundtrip.push(decoded); + } + verify_eq!(reconstruct(&comm_roundtrip, &shares_roundtrip[..3])?, secret)?; + + // Malformed Share protos: index 0, index > MAX_SHARES, wrong byte length, non-canonical + // scalar, zero scalar. + let zero_idx_proto = + proto!(FeldmanShareProto { index: 0, value: shares[0].value.as_bytes().to_vec() }); + verify_that!( + Share::from_proto(zero_idx_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let high_idx_proto = proto!(FeldmanShareProto { + index: MAX_SHARES + 1, + value: shares[0].value.as_bytes().to_vec(), + }); + verify_that!( + Share::from_proto(high_idx_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let short_val_proto = proto!(FeldmanShareProto { index: 1, value: vec![1u8; 31] }); + verify_that!( + Share::from_proto(short_val_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let non_canonical_val_proto = + proto!(FeldmanShareProto { index: 1, value: vec![0xFFu8; 32] }); + verify_that!( + Share::from_proto(non_canonical_val_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let zero_val_proto = proto!(FeldmanShareProto { index: 1, value: vec![0u8; 32] }); + verify_that!( + Share::from_proto(zero_val_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Malformed FeldmanCommitment protos: empty, > MAX_SHARES, wrong point byte length, + // identity point, invalid curve point. + let empty_comm_proto = proto!(FeldmanCommitmentProto {}); + verify_that!( + FeldmanCommitment::from_proto(empty_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let valid_pt_bytes = RistrettoPoint::mul_base(&Scalar::ONE).compress().to_bytes().to_vec(); + let oversized_comm_proto = proto!(FeldmanCommitmentProto { + commitments: (0..=MAX_SHARES).map(|_| valid_pt_bytes.clone()), + }); + verify_that!( + FeldmanCommitment::from_proto(oversized_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let short_pt_comm_proto = proto!(FeldmanCommitmentProto { commitments: [vec![1u8; 31]] }); + verify_that!( + FeldmanCommitment::from_proto(short_pt_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let identity_comm_proto = + proto!(FeldmanCommitmentProto { commitments: [vec![0u8; 32], valid_pt_bytes.clone()] }); + verify_that!( + FeldmanCommitment::from_proto(identity_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let leading_identity_comm_proto = + proto!(FeldmanCommitmentProto { commitments: [valid_pt_bytes, vec![0u8; 32]] }); + verify_that!( + FeldmanCommitment::from_proto(leading_identity_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + let invalid_curve_comm_proto = + proto!(FeldmanCommitmentProto { commitments: [vec![0xFFu8; 32]] }); + verify_that!( + FeldmanCommitment::from_proto(invalid_curve_comm_proto, ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_derive_keygen_seed_determinism_and_domain_separation() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let secret_a = SecretScalar::random(&mut prng)?; + let secret_b = SecretScalar::random(&mut prng)?; + let roster_a = [0x11u8; ROSTER_HASH_BYTE_LEN]; + let roster_b = [0x22u8; ROSTER_HASH_BYTE_LEN]; + let dealer_1 = NonZeroU32::new(1).unwrap(); + let dealer_2 = NonZeroU32::new(2).unwrap(); + + let seed_1 = derive_keygen_seed(&secret_a, b"session-ctx-1", &roster_a, dealer_1)?; + let seed_1_again = derive_keygen_seed(&secret_a, b"session-ctx-1", &roster_a, dealer_1)?; + + // Must be exactly `single_thread_hkdf::seed_length()` (64 bytes) and deterministic. + verify_eq!(seed_1.as_bytes().len(), 64)?; + verify_eq!(seed_1.as_bytes().len() as u64, single_thread_hkdf::seed_length())?; + verify_eq!(seed_1.as_bytes(), seed_1_again.as_bytes())?; + + // Must initialize SingleThreadHkdfPrng cleanly and replay identically after VSS + // reconstruction. + let (commitment, shares) = split(&secret_a, 2, 3, &mut prng)?; + let reconstructed = reconstruct(&commitment, &shares[..2])?; + let seed_from_reconstructed = + derive_keygen_seed(&reconstructed, b"session-ctx-1", &roster_a, dealer_1)?; + verify_eq!(seed_from_reconstructed.as_bytes(), seed_1.as_bytes())?; + + let mut prng_from_orig = SingleThreadHkdfPrng::create(&seed_1)?; + let mut prng_from_rec = SingleThreadHkdfPrng::create(&seed_from_reconstructed)?; + for _ in 0..32 { + verify_eq!(prng_from_orig.rand8()?, prng_from_rec.rand8()?)?; + } + + // Domain separation across secret, session_context (including trailing NUL byte), + // roster_hash, and dealer_index. + let seed_diff_secret = + derive_keygen_seed(&secret_b, b"session-ctx-1", &roster_a, dealer_1)?; + let seed_diff_ctx = derive_keygen_seed(&secret_a, b"session-ctx-2", &roster_a, dealer_1)?; + let seed_trailing_nul_ctx = + derive_keygen_seed(&secret_a, b"session-ctx-1\x00", &roster_a, dealer_1)?; + let seed_diff_roster = + derive_keygen_seed(&secret_a, b"session-ctx-1", &roster_b, dealer_1)?; + let seed_diff_dealer = + derive_keygen_seed(&secret_a, b"session-ctx-1", &roster_a, dealer_2)?; + + verify_eq!(seed_1.as_bytes() != seed_diff_secret.as_bytes(), true)?; + verify_eq!(seed_1.as_bytes() != seed_diff_ctx.as_bytes(), true)?; + verify_eq!(seed_1.as_bytes() != seed_trailing_nul_ctx.as_bytes(), true)?; + verify_eq!(seed_1.as_bytes() != seed_diff_roster.as_bytes(), true)?; + verify_eq!(seed_1.as_bytes() != seed_diff_dealer.as_bytes(), true)?; + + // Invalid inputs: zero scalar and dealer_index > MAX_SHARES. + let zero_secret = SecretScalar::new(Scalar::ZERO); + verify_that!( + derive_keygen_seed(&zero_secret, b"session-ctx-1", &roster_a, dealer_1).map(|_| ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + let bad_dealer = NonZeroU32::new(MAX_SHARES + 1).unwrap(); + verify_that!( + derive_keygen_seed(&secret_a, b"session-ctx-1", &roster_a, bad_dealer).map(|_| ()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } +} diff --git a/willow/crypto/parameters_utils_test.cc b/willow/crypto/parameters_utils_test.cc index 6af170e..38a386f 100644 --- a/willow/crypto/parameters_utils_test.cc +++ b/willow/crypto/parameters_utils_test.cc @@ -40,6 +40,17 @@ TEST(ParameterUtilsTest, CreateHumanReadableShellConfigTest) { ASSERT_TRUE(result.ok()); EXPECT_THAT(*result, ::testing::HasSubstr("ShellKaheConfig")); EXPECT_THAT(*result, ::testing::HasSubstr("ShellAheConfig")); + + config.set_max_number_of_decryptors(100); + auto multi_decryptor_result = CreateHumanReadableShellConfig(config); + ASSERT_TRUE(multi_decryptor_result.ok()); + EXPECT_THAT(*multi_decryptor_result, ::testing::HasSubstr("ShellKaheConfig")); + EXPECT_THAT(*multi_decryptor_result, ::testing::HasSubstr("ShellAheConfig")); + + config.set_max_number_of_decryptors(101); + auto too_many_decryptors = CreateHumanReadableShellConfig(config); + EXPECT_EQ(too_many_decryptors.status().code(), + absl::StatusCode::kInvalidArgument); } TEST(ParameterUtilsTest, CreateHumanReadableShellConfigInvalidConfigTest) { diff --git a/willow/crypto/share_cipher.rs b/willow/crypto/share_cipher.rs new file mode 100644 index 0000000..345f5ee --- /dev/null +++ b/willow/crypto/share_cipher.rs @@ -0,0 +1,663 @@ +// Copyright 2026 Google LLC +// +// 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. + +//! Peer share encryption (`ShareCipher`) for Willow multi-decryptor dropout recovery +//! over Tink C++ HPKE (`DHKEM_X25519_HKDF_SHA256`, `HKDF_SHA256`, `AES_128_GCM`). + +use curve25519_dalek::scalar::Scalar; +pub use feldman_vss::{ + derive_keygen_seed, SecretScalar, Share, KEYGEN_SEED_HKDF_INFO_PREFIX, MAX_SHARES, + ROSTER_HASH_BYTE_LEN, SCALAR_BYTE_LEN, +}; +use status::StatusError; +use std::num::NonZeroU32; + +/// Canonical domain-separation prefix for HPKE `context_info` when encrypting +/// Feldman VSS shares between committee members. +pub const SHARE_HPKE_CONTEXT_INFO_PREFIX: &[u8] = b"ShareCipher.hpke"; + +/// Builds the canonical length-prefixed HPKE `context_info`: +/// `"ShareCipher.hpke" || u32_le(session_context.len()) || session_context || roster_hash (32B) || +/// u32_le(dealer_index) || u32_le(recipient_index)` +/// +/// Rejects `dealer_index > MAX_SHARES`, `recipient_index > MAX_SHARES`, or +/// `dealer_index == recipient_index`. +pub fn build_share_hpke_context_info( + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, + recipient_index: NonZeroU32, +) -> Result, StatusError> { + if dealer_index.get() > MAX_SHARES { + return Err(status::invalid_argument( + "build_share_hpke_context_info: dealer_index exceeds MAX_SHARES", + )); + } + if recipient_index.get() > MAX_SHARES { + return Err(status::invalid_argument( + "build_share_hpke_context_info: recipient_index exceeds MAX_SHARES", + )); + } + if dealer_index == recipient_index { + return Err(status::invalid_argument( + "build_share_hpke_context_info: dealer_index and recipient_index must be distinct", + )); + } + let session_context_len = u32::try_from(session_context.len()).map_err(|_| { + status::invalid_argument( + "build_share_hpke_context_info: session_context length exceeds u32::MAX", + ) + })?; + + let total_len = SHARE_HPKE_CONTEXT_INFO_PREFIX.len() + + 4 + + session_context.len() + + ROSTER_HASH_BYTE_LEN + + 4 + + 4; + let mut info = Vec::with_capacity(total_len); + info.extend_from_slice(SHARE_HPKE_CONTEXT_INFO_PREFIX); + info.extend_from_slice(&session_context_len.to_le_bytes()); + info.extend_from_slice(session_context); + info.extend_from_slice(roster_hash); + info.extend_from_slice(&dealer_index.get().to_le_bytes()); + info.extend_from_slice(&recipient_index.get().to_le_bytes()); + Ok(info) +} + +/// Trait for encrypting and decrypting fixed 32-byte canonical Ristretto255 Feldman VSS +/// shares between committee members with domain-separated HPKE `context_info`. +pub trait ShareCipher { + /// Serialized or typed recipient public key. + type PublicKey: Clone + PartialEq + Eq + std::fmt::Debug; + /// Recipient private decryption key handle. + type PrivateKey: Clone + std::fmt::Debug; + + /// Generates a new `(PrivateKey, PublicKey)` pair. + fn generate_keypair() -> Result<(Self::PrivateKey, Self::PublicKey), StatusError>; + + /// Validates that `public_key` is well-formed and usable for share encryption. + fn validate_public_key(public_key: &Self::PublicKey) -> status::Status; + + /// Encrypts `share` (evaluated at `recipient_index = share.index`) from `dealer_index` + /// under `recipient_public_key`, binding `(session_context, roster_hash, dealer_index, + /// share.index)` in HPKE `context_info`. + fn encrypt_share( + recipient_public_key: &Self::PublicKey, + share: &Share, + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, + ) -> Result, StatusError>; + + /// Decrypts `ciphertext` sent by `dealer_index` to `recipient_index` under + /// `recipient_private_key`, verifying the bound `(session_context, roster_hash, dealer_index, + /// recipient_index)` in HPKE `context_info` and validating that the decrypted plaintext is a + /// canonical non-zero 32-byte Ristretto255 scalar. + /// + /// # Security Contract (HPKE Base Mode & Broadcast Commitment Verification) + /// + /// `TinkHpkeShareCipher` uses HPKE Base Mode (`mode_base = 0x00`), which provides + /// confidentiality and context-bound ciphertext integrity under `recipient_public_key`, + /// but does **not** authenticate the sender (`dealer_index`): any party holding + /// `recipient_public_key` (including an untrusted Coordinator) can construct a well-formed + /// HPKE ciphertext for `(session_context, roster_hash, dealer_index, recipient_index)`. + /// + /// Therefore, after `decrypt_share` returns `Ok(share)`, the protocol layer **MUST**: + /// 1. Keep `roster_hash` fixed over the pre-setup committee roster `(threshold, num_decryptors, + /// sorted [(decryptor_index, hpke_public_key)])` (since `roster_hash` is an input to + /// `derive_keygen_seed` in Phase 1 and `build_share_hpke_context_info` in Phase 2), bind all + /// committee members' broadcast `FeldmanCommitment`s in the Phase-1 setup transcript + /// (`A2`/`A6`), and verify `dealer_commitment.threshold() == committee_config.threshold` + /// (`A6`). + /// 2. Call `feldman_vss::verify_share(&dealer_commitment, &share)` and reject the dealer (or + /// abort setup) if `[s_{i->j}] * G != \sum_{k=0}^{t-1} (j)^k * C_{i,k}`. + fn decrypt_share( + recipient_private_key: &Self::PrivateKey, + ciphertext: &[u8], + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, + recipient_index: NonZeroU32, + ) -> Result; +} + +/// Serialized Tink HPKE public keyset (`ProtoKeysetFormat`, compatible with Android/C++ Tink +/// `HybridEncrypt` keysets). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TinkHpkePublicKey(Vec); + +impl TinkHpkePublicKey { + /// Parses and validates a serialized Tink `HybridEncrypt` public keyset in `ProtoKeysetFormat`. + pub fn from_bytes(bytes: Vec) -> Result { + hpke::validate_hpke_public_keyset(&bytes)?; + Ok(Self(bytes)) + } + + /// Returns the serialized public keyset bytes in `ProtoKeysetFormat`. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Consumes `self` and returns the underlying serialized public keyset bytes. + pub fn into_bytes(self) -> Vec { + self.0 + } +} + +/// Opaque Tink HPKE private keyset handle (`KeysetHandle` for `HpkeX25519HkdfSha256Aes128Gcm`). +#[derive(Clone)] +pub struct TinkHpkePrivateKey(hpke::HpkeKeyset); + +impl std::fmt::Debug for TinkHpkePrivateKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TinkHpkePrivateKey([REDACTED])") + } +} + +/// Opaque Tink AES-256-GCM AEAD keyset handle for encrypting/decrypting `TinkHpkePrivateKey` +/// via `SerializeKeysetToEncryptedKeysetFormat` / `ParseKeysetFromEncryptedKeysetFormat`. +#[derive(Clone)] +pub struct TinkHpkeKeysetWrappingKey(hpke::HpkeKeyset); + +impl std::fmt::Debug for TinkHpkeKeysetWrappingKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TinkHpkeKeysetWrappingKey([REDACTED])") + } +} + +impl TinkHpkeKeysetWrappingKey { + /// Generates a new AES-256-GCM keyset wrapping key. + pub fn generate() -> Result { + Ok(Self(hpke::generate_wrapping_aead()?)) + } +} + +impl TinkHpkePrivateKey { + /// Derives the corresponding `TinkHpkePublicKey` in Tink `ProtoKeysetFormat`. + pub fn public_key(&self) -> Result { + let bytes = hpke::get_public_keyset_from_private(&self.0)?; + Ok(TinkHpkePublicKey(bytes)) + } + + /// Serializes this HPKE private keyset in Tink `EncryptedKeysetFormat` under `wrapping_key` + /// and `associated_data` (`SerializeKeysetToEncryptedKeysetFormat`). + pub fn to_encrypted_keyset( + &self, + wrapping_key: &TinkHpkeKeysetWrappingKey, + associated_data: &[u8], + ) -> Result, StatusError> { + hpke::serialize_hpke_private_keyset_encrypted(&self.0, &wrapping_key.0, associated_data) + } + + /// Restores a `TinkHpkePrivateKey` from Tink `EncryptedKeysetFormat` under `wrapping_key` + /// and `associated_data` (`ParseKeysetFromEncryptedKeysetFormat`). + pub fn from_encrypted_keyset( + encrypted_keyset: &[u8], + wrapping_key: &TinkHpkeKeysetWrappingKey, + associated_data: &[u8], + ) -> Result { + let keyset = hpke::parse_hpke_private_keyset_encrypted( + encrypted_keyset, + &wrapping_key.0, + associated_data, + )?; + Ok(Self(keyset)) + } +} + +/// `@tink_cc` HPKE implementation of [`ShareCipher`] using +/// `DHKEM_X25519_HKDF_SHA256` / `HKDF_SHA256` / `AES_128_GCM`. +#[derive(Clone, Copy, Debug, Default)] +pub struct TinkHpkeShareCipher; + +impl TinkHpkeShareCipher { + /// Generates a new HPKE keypair using `HpkeX25519HkdfSha256Aes128GcmRaw()` + /// (`OutputPrefixType::RAW`). Both `TINK`-prefixed and `RAW`-prefixed keysets are accepted by + /// `encrypt_share` and `decrypt_share`. + pub fn generate_raw_keypair() -> Result<(TinkHpkePrivateKey, TinkHpkePublicKey), StatusError> { + let (sk, pk_bytes) = hpke::generate_hpke_keyset(true)?; + Ok((TinkHpkePrivateKey(sk), TinkHpkePublicKey(pk_bytes))) + } +} + +impl ShareCipher for TinkHpkeShareCipher { + type PublicKey = TinkHpkePublicKey; + type PrivateKey = TinkHpkePrivateKey; + + fn generate_keypair() -> Result<(Self::PrivateKey, Self::PublicKey), StatusError> { + let (sk, pk_bytes) = hpke::generate_hpke_keyset(false)?; + Ok((TinkHpkePrivateKey(sk), TinkHpkePublicKey(pk_bytes))) + } + + fn validate_public_key(public_key: &Self::PublicKey) -> status::Status { + hpke::validate_hpke_public_keyset(public_key.as_bytes()) + } + + fn encrypt_share( + recipient_public_key: &Self::PublicKey, + share: &Share, + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, + ) -> Result, StatusError> { + if *share.value.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument( + "encrypt_share: share scalar value must be non-zero", + )); + } + let context_info = + build_share_hpke_context_info(session_context, roster_hash, dealer_index, share.index)?; + let plaintext = share.value.to_bytes(); + hpke::hpke_encrypt(recipient_public_key.as_bytes(), &plaintext, &context_info) + } + + fn decrypt_share( + recipient_private_key: &Self::PrivateKey, + ciphertext: &[u8], + session_context: &[u8], + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + dealer_index: NonZeroU32, + recipient_index: NonZeroU32, + ) -> Result { + let context_info = build_share_hpke_context_info( + session_context, + roster_hash, + dealer_index, + recipient_index, + )?; + let plaintext = hpke::hpke_decrypt(&recipient_private_key.0, ciphertext, &context_info)?; + if plaintext.len() != SCALAR_BYTE_LEN { + return Err(status::invalid_argument( + "decrypt_share: decrypted HPKE payload must be exactly 32 bytes", + )); + } + let value = SecretScalar::from_canonical_bytes(&plaintext)?; + if *value.as_scalar() == Scalar::ZERO { + return Err(status::invalid_argument( + "decrypt_share: decrypted share scalar must be non-zero", + )); + } + Ok(Share { index: recipient_index, value }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use feldman_vss::{reconstruct, split, verify_share}; + use googletest::{gtest, verify_eq, verify_that, Result as GTestResult}; + use prng_traits::SecurePrng; + use single_thread_hkdf::SingleThreadHkdfPrng; + use status::StatusErrorCode; + use status_matchers_rs::status_is; + + fn create_test_prng() -> Result { + let seed = SingleThreadHkdfPrng::generate_seed()?; + SingleThreadHkdfPrng::create(&seed) + } + + #[gtest] + fn test_context_info_canonical_encoding_and_length_prefixing() -> GTestResult<()> { + let session_ctx = b"session-42"; + let roster_hash = [0xABu8; ROSTER_HASH_BYTE_LEN]; + let dealer = NonZeroU32::new(1).unwrap(); + let recipient = NonZeroU32::new(7).unwrap(); + + let info = build_share_hpke_context_info(session_ctx, &roster_hash, dealer, recipient)?; + let mut expected = Vec::new(); + expected.extend_from_slice(b"ShareCipher.hpke"); + expected.extend_from_slice(&(session_ctx.len() as u32).to_le_bytes()); + expected.extend_from_slice(session_ctx); + expected.extend_from_slice(&roster_hash); + expected.extend_from_slice(&1u32.to_le_bytes()); + expected.extend_from_slice(&7u32.to_le_bytes()); + verify_eq!(info, expected)?; + + // Self-dealing (dealer == recipient) and out-of-range indices (> MAX_SHARES) are rejected. + verify_that!( + build_share_hpke_context_info(session_ctx, &roster_hash, dealer, dealer), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + let out_of_range = NonZeroU32::new(MAX_SHARES + 1).unwrap(); + verify_that!( + build_share_hpke_context_info(session_ctx, &roster_hash, out_of_range, recipient), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + build_share_hpke_context_info(session_ctx, &roster_hash, dealer, out_of_range), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_hpke_share_cipher_roundtrip_and_vss_recovery() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let session_ctx = b"willow-session-roundtrip"; + let roster_hash = [0x5Au8; ROSTER_HASH_BYTE_LEN]; + + // 5-member committee; member 2 is the dropped dealer. + let num_decryptors = 5u32; + let threshold = 3u32; + let dealer_index = NonZeroU32::new(2).unwrap(); + + let mut keypairs = Vec::with_capacity(num_decryptors as usize); + for i in 1..=num_decryptors { + let kp = if i % 2 == 0 { + TinkHpkeShareCipher::generate_raw_keypair()? + } else { + TinkHpkeShareCipher::generate_keypair()? + }; + TinkHpkeShareCipher::validate_public_key(&kp.1)?; + keypairs.push(kp); + } + + let dealer_secret = SecretScalar::random(&mut prng)?; + let expected_keygen_seed = + derive_keygen_seed(&dealer_secret, session_ctx, &roster_hash, dealer_index)?; + let (commitment, shares) = split(&dealer_secret, threshold, num_decryptors, &mut prng)?; + + // Dealer 2 encrypts shares for all n - 1 peers (skipping share.index == dealer_index). + let mut encrypted_peer_shares = Vec::new(); + for share in &shares { + if share.index == dealer_index { + continue; + } + let recipient_idx = (share.index.get() - 1) as usize; + let ct = TinkHpkeShareCipher::encrypt_share( + &keypairs[recipient_idx].1, + share, + session_ctx, + &roster_hash, + dealer_index, + )?; + encrypted_peer_shares.push((share.index, ct)); + } + verify_eq!(encrypted_peer_shares.len(), 4)?; + + // Survivors {1, 3, 5} decrypt their shares for dropped dealer 2. + let mut decrypted_shares = Vec::new(); + for &(recipient_index, ref ct) in &[ + encrypted_peer_shares[0].clone(), // member 1 + encrypted_peer_shares[1].clone(), // member 3 + encrypted_peer_shares[3].clone(), // member 5 + ] { + let sk = &keypairs[(recipient_index.get() - 1) as usize].0; + let decrypted_share = TinkHpkeShareCipher::decrypt_share( + sk, + ct, + session_ctx, + &roster_hash, + dealer_index, + recipient_index, + )?; + verify_share(&commitment, &decrypted_share)?; + decrypted_shares.push(decrypted_share); + } + + let reconstructed_secret = reconstruct(&commitment, &decrypted_shares)?; + verify_eq!(reconstructed_secret, dealer_secret)?; + let reconstructed_seed = + derive_keygen_seed(&reconstructed_secret, session_ctx, &roster_hash, dealer_index)?; + verify_eq!(reconstructed_seed.as_bytes(), expected_keygen_seed.as_bytes())?; + + Ok(()) + } + + #[gtest] + fn test_wrong_key_tampered_ciphertext_and_swapped_context_rejected() -> GTestResult<()> { + let mut prng = create_test_prng()?; + let session_ctx = b"willow-session-adversarial"; + let roster_hash = [0x3Cu8; ROSTER_HASH_BYTE_LEN]; + let dealer_1 = NonZeroU32::new(1).unwrap(); + let dealer_2 = NonZeroU32::new(2).unwrap(); + let recipient_3 = NonZeroU32::new(3).unwrap(); + let recipient_4 = NonZeroU32::new(4).unwrap(); + + let (sk_3, pk_3) = TinkHpkeShareCipher::generate_keypair()?; + let (sk_4, _pk_4) = TinkHpkeShareCipher::generate_keypair()?; + + let secret = SecretScalar::random(&mut prng)?; + let (_commitment, shares) = split(&secret, 2, 4, &mut prng)?; + let share_for_3 = &shares[2]; // index == 3 + verify_eq!(share_for_3.index, recipient_3)?; + + let ct = TinkHpkeShareCipher::encrypt_share( + &pk_3, + share_for_3, + session_ctx, + &roster_hash, + dealer_1, + )?; + + // 1. Wrong recipient private key (member 4 trying to decrypt share for member 3). + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_4, + &ct, + session_ctx, + &roster_hash, + dealer_1, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 2. Swapped dealer_index and recipient_index. + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + session_ctx, + &roster_hash, + recipient_3, + dealer_1 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 3. Spliced dealer_index (Coordinator claiming dealer 1's share came from dealer 2). + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + session_ctx, + &roster_hash, + dealer_2, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 4. Spliced recipient_index (member 3 asked to decrypt with recipient_index = 4). + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + session_ctx, + &roster_hash, + dealer_1, + recipient_4 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 5. Wrong session_context (including trailing NUL byte) or wrong roster_hash. + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + b"willow-session-adversarial\x00", + &roster_hash, + dealer_1, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + let wrong_roster = [0x99u8; ROSTER_HASH_BYTE_LEN]; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + session_ctx, + &wrong_roster, + dealer_1, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 6. Tampered ciphertext byte and truncated ciphertext. + let mut tampered_ct = ct.clone(); + let last_idx = tampered_ct.len() - 1; + tampered_ct[last_idx] ^= 0x01; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &tampered_ct, + session_ctx, + &roster_hash, + dealer_1, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct[..10], + session_ctx, + &roster_hash, + dealer_1, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 7. Self-dealing (`share.index == dealer_index`) rejected on both encrypt and decrypt. + let self_share = &shares[0]; // index == 1 == dealer_1 + verify_that!( + TinkHpkeShareCipher::encrypt_share( + &pk_3, + self_share, + session_ctx, + &roster_hash, + dealer_1 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk_3, + &ct, + session_ctx, + &roster_hash, + recipient_3, + recipient_3 + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_malformed_scalar_plaintext_inside_valid_hpke_ciphertext_rejected() -> GTestResult<()> { + let session_ctx = b"willow-session-malformed-pt"; + let roster_hash = [0x7Eu8; ROSTER_HASH_BYTE_LEN]; + let dealer = NonZeroU32::new(1).unwrap(); + let recipient = NonZeroU32::new(2).unwrap(); + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + let context_info = + build_share_hpke_context_info(session_ctx, &roster_hash, dealer, recipient)?; + + // 31-byte plaintext. + let ct_short = hpke::hpke_encrypt(pk.as_bytes(), &[1u8; 31], &context_info)?; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk, + &ct_short, + session_ctx, + &roster_hash, + dealer, + recipient + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 32-byte all-0xFF non-canonical scalar (>= group order l). + let ct_noncanonical = hpke::hpke_encrypt(pk.as_bytes(), &[0xFFu8; 32], &context_info)?; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk, + &ct_noncanonical, + session_ctx, + &roster_hash, + dealer, + recipient + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // 32-byte all-zero scalar. + let ct_zero = hpke::hpke_encrypt(pk.as_bytes(), &[0u8; 32], &context_info)?; + verify_that!( + TinkHpkeShareCipher::decrypt_share( + &sk, + &ct_zero, + session_ctx, + &roster_hash, + dealer, + recipient + ), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + Ok(()) + } + + #[gtest] + fn test_encrypted_keyset_persistence_and_public_key_validation() -> GTestResult<()> { + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + let roundtrip_pk = TinkHpkePublicKey::from_bytes(pk.as_bytes().to_vec())?; + verify_eq!(roundtrip_pk, pk)?; + verify_eq!(sk.public_key()?, pk)?; + + // Invalid public keyset bytes rejected. + verify_that!( + TinkHpkePublicKey::from_bytes(vec![]), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + TinkHpkePublicKey::from_bytes(b"not-a-tink-keyset".to_vec()), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Encrypted private keyset persistence round-trip. + let wrapping_key = TinkHpkeKeysetWrappingKey::generate()?; + let ad = b"decryptor-state-v1"; + let encrypted_sk = sk.to_encrypted_keyset(&wrapping_key, ad)?; + let restored_sk = + TinkHpkePrivateKey::from_encrypted_keyset(&encrypted_sk, &wrapping_key, ad)?; + verify_eq!(restored_sk.public_key()?, pk)?; + + // Debug redaction check. + let dbg = format!("{:?} {:?}", sk, wrapping_key); + verify_eq!(dbg.contains("REDACTED"), true)?; + + Ok(()) + } +} diff --git a/willow/crypto/shell_ahe.rs b/willow/crypto/shell_ahe.rs index cbe4c16..a244ca0 100644 --- a/willow/crypto/shell_ahe.rs +++ b/willow/crypto/shell_ahe.rs @@ -350,7 +350,7 @@ impl ShellAhe { #[derive(Clone)] pub struct SecretKeyShare(pub RnsPolynomial); -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] pub struct PublicKeyShare(pub RnsPolynomial); #[derive(Clone)] diff --git a/willow/crypto/shell_parameters.rs b/willow/crypto/shell_parameters.rs index b90cf88..4d6a908 100644 --- a/willow/crypto/shell_parameters.rs +++ b/willow/crypto/shell_parameters.rs @@ -33,15 +33,21 @@ use shell_parameters_generation::{divide_and_roundup, generate_packing_config}; /// ---------------------------------------------------------------------------- /// AHE parameters are fixed across all input settings. /// ---------------------------------------------------------------------------- -/// This set of AHE parameters are good for the following assumption: -/// - there are at most 10^7 clients, and at least 99.999% of them are honest; -/// - there are at most 100 decryptors, and all of them are honest; -/// - verifiable key gen, encryption, and partial decryption use approximate L_inf range proofs. +/// This set of AHE parameters is selected for up to 100 decryptors and up to +/// 10^7 clients under approximate L_inf range proofs: +/// - Privacy (statistical smudging with `AHE_FIXED_S_FLOOD` at >= 40 bits of statistical security) +/// holds as long as at least one surviving decryptor whose flooding noise is included in the +/// partial decryption sum is honest (e.g., the Reputable Decryptor TEE in the hybrid +/// multi-decryptor model) and at least 99.999% of the 10^7 clients are honest (up to 356 +/// malicious clients under the `zk_rlwe_relation.rs` L_inf bound `gamma_inf = 162,500`, +/// Regardless of how many of the remaining <= 99 decryptors are corrupted). +/// - Decryption correctness (`||e_total||_inf < Delta / 2`) holds for up to 100 decryptors with a > +/// 39x safety margin. const AHE_FIXED_LOG_N: u64 = 12; const AHE_FIXED_T: u64 = 262145; // 2^18 + 1 const AHE_FIXED_QS: [u64; 2] = [281474976546817, 281474975662081]; // 96 bits total const AHE_FIXED_S_FLOOD: f64 = 4.81659e+19; -const AHE_FIXED_MAX_NUM_DECRYPTORS: i64 = 1; +const AHE_FIXED_MAX_NUM_DECRYPTORS: i64 = 100; /// ---------------------------------------------------------------------------- /// KAHE parameters for some typical input settings. @@ -81,6 +87,9 @@ const KAHE_FOR_FIXED_AHE_QS_10M_10M: [u64; 4] = KAHE_FOR_FIXED_AHE_QS_100K_10M; pub fn create_shell_ahe_config( max_number_of_decryptors: i64, ) -> Result { + if max_number_of_decryptors <= 0 { + return Err(status::invalid_argument("`max_number_of_decryptors` must be positive")); + } if max_number_of_decryptors > AHE_FIXED_MAX_NUM_DECRYPTORS { return Err(status::invalid_argument(&format!( "`max_number_of_decryptors` cannot be larger than {}", @@ -99,6 +108,15 @@ pub fn create_shell_ahe_config( pub fn create_shell_kahe_config( aggregation_config: &AggregationConfig, ) -> Result { + if aggregation_config.max_number_of_decryptors <= 0 + || aggregation_config.max_number_of_decryptors > AHE_FIXED_MAX_NUM_DECRYPTORS + { + return Err(status::invalid_argument(&format!( + "`max_number_of_decryptors` must be in [1, {}], got {}", + AHE_FIXED_MAX_NUM_DECRYPTORS, aggregation_config.max_number_of_decryptors + ))); + } + // Use heuristics to select parameters. let total_input_length: i64 = aggregation_config .vector_lengths_and_bounds @@ -113,7 +131,7 @@ pub fn create_shell_kahe_config( if total_input_length <= 1000 && *max_input_bound <= (1i64 << 32) && aggregation_config.max_number_of_clients <= 10_000_000 - && aggregation_config.max_number_of_decryptors <= 100 + && aggregation_config.max_number_of_decryptors <= AHE_FIXED_MAX_NUM_DECRYPTORS { let packed_vector_configs = generate_packing_config(KAHE_FOR_FIXED_AHE_LOG_T_1K_10M, aggregation_config)?; @@ -134,7 +152,7 @@ pub fn create_shell_kahe_config( if total_input_length <= 100_000 && *max_input_bound <= (1i64 << 32) && aggregation_config.max_number_of_clients <= 10_000_000 - && aggregation_config.max_number_of_decryptors <= 100 + && aggregation_config.max_number_of_decryptors <= AHE_FIXED_MAX_NUM_DECRYPTORS { let packed_vector_configs = generate_packing_config(KAHE_FOR_FIXED_AHE_LOG_T_100K_10M, aggregation_config)?; @@ -155,7 +173,7 @@ pub fn create_shell_kahe_config( if total_input_length <= 10_000_000 && *max_input_bound <= (1i64 << 32) && aggregation_config.max_number_of_clients <= 10_000_000 - && aggregation_config.max_number_of_decryptors <= 100 + && aggregation_config.max_number_of_decryptors <= AHE_FIXED_MAX_NUM_DECRYPTORS { let packed_vector_configs = generate_packing_config(KAHE_FOR_FIXED_AHE_LOG_T_10M_10M, aggregation_config)?; @@ -188,3 +206,69 @@ pub fn create_shell_configs( let kahe_config = create_shell_kahe_config(&aggregation_config)?; Ok((kahe_config, ahe_config)) } + +#[cfg(test)] +mod test { + use super::*; + use googletest::prelude::*; + use status::StatusErrorCode; + use status_matchers_rs::status_is; + use std::collections::HashMap; + + fn sample_aggregation_config(max_number_of_decryptors: i64) -> AggregationConfig { + AggregationConfig { + vector_lengths_and_bounds: HashMap::from([(String::from("v"), (16, 100))]), + max_number_of_decryptors, + max_decryptor_dropouts: 0, + max_number_of_clients: 10, + key_id: b"test_key".to_vec(), + } + } + + #[gtest] + fn create_shell_ahe_config_supports_up_to_100_decryptors() -> Result<()> { + for num_decryptors in [1, 2, 6, 50, 100] { + let cfg = create_shell_ahe_config(num_decryptors)?; + verify_eq!(cfg.log_n, AHE_FIXED_LOG_N)?; + verify_eq!(cfg.t, AHE_FIXED_T)?; + verify_eq!(cfg.qs, AHE_FIXED_QS.to_vec())?; + verify_eq!(cfg.s_flood, AHE_FIXED_S_FLOOD)?; + } + Ok(()) + } + + #[gtest] + fn create_shell_ahe_config_rejects_out_of_range_decryptors() -> Result<()> { + for invalid in [-5, 0, 101, 1000] { + verify_that!( + create_shell_ahe_config(invalid), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + } + Ok(()) + } + + #[gtest] + fn create_shell_configs_validates_decryptor_bounds() -> Result<()> { + verify_that!(create_shell_configs(&sample_aggregation_config(1)), ok(anything()))?; + verify_that!(create_shell_configs(&sample_aggregation_config(6)), ok(anything()))?; + verify_that!(create_shell_configs(&sample_aggregation_config(100)), ok(anything()))?; + verify_that!( + create_shell_configs(&sample_aggregation_config(0)), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + create_shell_configs(&sample_aggregation_config(101)), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + create_shell_kahe_config(&sample_aggregation_config(0)), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + verify_that!( + create_shell_kahe_config(&sample_aggregation_config(101)), + status_is(StatusErrorCode::INVALID_ARGUMENT) + )?; + Ok(()) + } +} diff --git a/willow/crypto/zk_rlwe_relation.rs b/willow/crypto/zk_rlwe_relation.rs index f3c1548..5768316 100644 --- a/willow/crypto/zk_rlwe_relation.rs +++ b/willow/crypto/zk_rlwe_relation.rs @@ -681,10 +681,10 @@ impl<'a> ZeroKnowledgeProver, RlweRelationProofWi let rho = rho_vec[j]; // Evaluate the private polynomials at rho. // Unlike the other polynomials we are evaluating (a*r) and (v*p) can exceed 2^128 (for - // large q) so we split the polynomials in this section into upper and lower - // parts to avoid overflow. We use base q as this will be convenient in the - // following calculations and we don't mind the smaller part exceeding q so - // long as it doesn't overflow the 128bit type. + // large q) so we split the polynomials in this section into upper and lower parts to + // avoid overflow. We use base q as this will be convenient in the following + // calculations and we don't mind the smaller part exceeding q so long as it doesn't + // overflow the 128bit type. // // We make the upper part signed so we can represent negative numbers. let mut arrho_upper = 0i128; @@ -753,8 +753,7 @@ impl<'a> ZeroKnowledgeProver, RlweRelationProofWi // powers of rho. // The concatenation of these public vectors we now write into the public_vec for the inner // product proof. Followed by the public half of -q*wrho i.e. just the scalar -q (with the - // relevant tau powers). The expected result of the inner product is also computed - // here. + // relevant tau powers). The expected result of the inner product is also computed here. let (mut public_vec, mut result) = create_public_vec( &rho_vec, &arho_vec, diff --git a/willow/proto/willow/messages.proto b/willow/proto/willow/messages.proto index 8aa7c05..ddf223b 100644 --- a/willow/proto/willow/messages.proto +++ b/willow/proto/willow/messages.proto @@ -40,6 +40,9 @@ message PartialDecryptionResponse { ShellAhePartialDecryption partial_decryption = 1; // Noise contribution to the final aggregated result. CiphertextContribution dp_ciphertext_contribution = 2; + // 1-based committee index of the responding decryptor (1..=MAX_SHARES) in + // recoverable multi-decryptor mode (0 if unindexed / reputable / single). + uint32 decryptor_index = 3; } message CiphertextContribution { @@ -53,9 +56,51 @@ message DecryptionRequestContribution { bytes nonce = 3; } +// Tracks a decryptor's progress through the protocol for strict one-shot +// state transition enforcement. +enum DecryptorStatus { + DECRYPTOR_STATUS_UNSPECIFIED = 0; + DECRYPTOR_STATUS_PRE_SETUP = 1; + DECRYPTOR_STATUS_AWAITING_DECRYPTION_REQUEST = 2; + DECRYPTOR_STATUS_AWAITING_RECOVERY_REQUEST = 3; + DECRYPTOR_STATUS_FINISHED = 4; +} + +// Configuration for a non-reputable decryptor committee member participating +// in Feldman VSS + HPKE dropout recovery. +message CommitteeConfigProto { + // 1-based index of this decryptor in 1..=num_decryptors. + uint32 my_index = 1; + // Total number of decryptors in the committee (2 <= num_decryptors <= MAX_SHARES). + uint32 num_decryptors = 2; + // Reconstruction threshold t (1 <= threshold <= num_decryptors - 1). + uint32 threshold = 3; + // 32-byte hash over the pre-setup committee roster + // (threshold, num_decryptors, sorted [(decryptor_index, hpke_public_key)]). + bytes roster_hash = 4; + // Session context / key_id binding shares and seeds to this aggregation session. + bytes session_context = 5; +} + message DecryptorState { ShellAheSecretKeyShare sk_share = 1; AggregationConfigProto aggregation_config = 2; + DecryptorStatus status = 3; + CommitteeConfigProto committee_config = 4; + // Optional encrypted Tink HPKE private keyset (`EncryptedKeysetFormat` produced + // by `TinkHpkePrivateKey::to_encrypted_keyset`) for decrypting peer shares + // during dropout recovery. + bytes hpke_private_keyset = 5; + // Broadcast Feldman VSS polynomial commitments keyed by 1-based dealer_index + // (1..=MAX_SHARES), bound in the setup transcript. + map dealer_commitments = 6; + // Optional 32-byte setup transcript hash binding all broadcast + // (dealer_index, FeldmanCommitment) pairs. + bytes setup_transcript_hash = 7; + // Optional own public key share generated during setup, used by reputable + // decryptors to verify that VerifyKeyContributionsRequest includes their own + // key share. + ShellAhePublicKeyShare pk_share = 8; } message ServerState { @@ -86,6 +131,23 @@ message FinalizedAccumulatorResult { // An encrypted share of the PRNG state for secret sharing. message SecretSharingContribution { bytes encrypted_share = 1; + // 1-based recipient decryptor index (1..=MAX_SHARES) for which this share + // was encrypted (0 if unindexed). + uint32 recipient_index = 2; +} + +// A plaintext Feldman VSS share (index, f(index)) over Ristretto255. +message FeldmanShareProto { + // 1-based share evaluation index in 1..=MAX_SHARES. + uint32 index = 1; + // Canonical 32-byte little-endian Ristretto255 scalar encoding. + bytes value = 2; +} + +// Public Feldman VSS polynomial commitment (C_0, ..., C_{t-1}) over Ristretto255. +message FeldmanCommitmentProto { + // Canonical 32-byte compressed Ristretto255 points C_k = a_k * G. + repeated bytes commitments = 1; } // Public key independent half of ciphertext for DP noise. @@ -110,16 +172,37 @@ message SetupContribution { DPSetupContribution dp_setup = 2; // Encrypted randomness shares, only used by non-reputable decryptors. repeated SecretSharingContribution encrypted_randomness_shares = 3; + // 1-based committee index of this decryptor (1..=MAX_SHARES) in + // recoverable multi-decryptor mode (0 if unindexed / reputable). + uint32 decryptor_index = 4; + // Public Feldman VSS polynomial commitment (C_0, ..., C_{t-1}) from this + // dealer in recoverable multi-decryptor mode. + FeldmanCommitmentProto feldman_commitment = 5; + // Encrypted randomness shares keyed by 1-based recipient_index + // (1..=MAX_SHARES). + map indexed_encrypted_shares = 6; } // A request to a surviving decryptor to decrypt shares of dropped decryptors. message RecoveryRequest { repeated SecretSharingContribution encrypted_shares = 1; + // Encrypted shares keyed by 1-based dropped dealer_index (1..=MAX_SHARES). + map indexed_encrypted_shares = 2; + // Broadcast Feldman VSS commitments keyed by 1-based dealer_index + // (1..=MAX_SHARES), used by the surviving decryptor to verify the setup + // commitment transcript and each decrypted share (`feldman_vss::verify_share`). + map dealer_commitments = 3; } // A response containing the decrypted shares of dropped decryptors. message RecoveryResponse { repeated bytes decrypted_shares = 1; + // 1-based committee index of the surviving decryptor (1..=MAX_SHARES) + // sending this response. + uint32 survivor_index = 2; + // Decrypted Feldman VSS shares keyed by 1-based dropped dealer_index + // (1..=MAX_SHARES). + map indexed_decrypted_shares = 3; } // A request from the coordinator to reputable decryptors to verify key @@ -151,6 +234,21 @@ message EncryptedRandomnessShares { repeated SecretSharingContribution shares = 1; } +// Per-dealer recoverable setup metadata stored by the coordinator for +// Feldman VSS dropout recovery. +message DealerSetupInfoProto { + // 1-based committee index of the dealer (1..=MAX_SHARES). + uint32 dealer_index = 1; + // Broadcast Feldman VSS polynomial commitment (C_0, ..., C_{t-1}). + FeldmanCommitmentProto feldman_commitment = 2; + // Public key share submitted by this dealer during setup (used to verify + // regenerated pk_share == expected_pk_share during recovery). + ShellAhePublicKeyShare public_key_share = 3; + // Encrypted randomness shares dealt by this dealer, keyed by 1-based + // recipient_index (1..=MAX_SHARES). + map encrypted_shares = 4; +} + // State stored by the coordinator between protocol rounds. message CoordinatorState { CoordinatorStatus status = 1; @@ -168,4 +266,20 @@ message CoordinatorState { ShellAhePartialDecryption partial_decryption_sum = 5; // Aggregation configuration used to initialize the coordinator. AggregationConfigProto aggregation_config = 6; + // 1-based committee indices of non-reputable decryptors that have + // responded with a PartialDecryptionResponse. + repeated uint32 responded_decryptor_indices = 7; + // Saved partial decryption request ciphertext (ct_1) from + // prepare_decryption_request, needed to compute simulated partial + // decryptions for dropped decryptors during recovery. + ShellAhePartialDecCiphertext partial_dec_ciphertext = 8; + // Per-dealer Feldman VSS commitment, setup PublicKeyShare, and encrypted + // shares keyed by 1-based dealer_index (1..=MAX_SHARES). + map dealer_setup_info = 9; + // Committee configuration (num_decryptors, threshold, roster_hash, + // session_context) when running with a recoverable committee. + CommitteeConfigProto committee_config = 10; + // 32-byte setup transcript hash binding all broadcast + // (dealer_index, FeldmanCommitment) pairs. + bytes setup_transcript_hash = 11; } diff --git a/willow/protocol/BUILD b/willow/protocol/BUILD index 3a4f377..51a1a8b 100644 --- a/willow/protocol/BUILD +++ b/willow/protocol/BUILD @@ -32,7 +32,9 @@ rust_library( "//willow/api:aggregation_config", "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:kahe_traits", + "//willow/crypto:single_thread_hkdf", "//willow/crypto:vahe_traits", "//willow/proto/shell:shell_ciphertexts_rust_proto", "//willow/proto/willow:aggregation_config_rust_proto", @@ -41,6 +43,18 @@ rust_library( ], ) +rust_test( + name = "messages_test", + crate = ":messages", + deps = [ + "@crate_index//:googletest", + "//willow/crypto:prng_traits", + "//willow/crypto:shell_kahe", + "//willow/crypto:shell_parameters", + "//willow/crypto:shell_vahe", + ], +) + # Protocol level traits rust_library( name = "client_traits", @@ -118,10 +132,13 @@ rust_test( deps = [ ":messages", "@crate_index//:googletest", + "//ffi_utils:status_matchers_rs", "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:kahe_traits", "//willow/crypto:prng_traits", + "//willow/crypto:share_cipher", "//willow/crypto:shell_kahe", "//willow/crypto:shell_parameters", "//willow/crypto:shell_vahe", @@ -143,10 +160,13 @@ rust_library( "//willow/api:aggregation_config", "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:kahe_traits", "//willow/crypto:prng_traits", + "//willow/crypto:share_cipher", "//willow/crypto:shell_kahe", "//willow/crypto:shell_parameters", + "//willow/crypto:single_thread_hkdf", "//willow/crypto:vahe_traits", "//willow/proto/shell:shell_ciphertexts_rust_proto", "//willow/proto/willow:messages_rust_proto", @@ -249,10 +269,16 @@ rust_library( ], deps = [ ":messages", + "@protobuf//rust:protobuf", "//ffi_utils:status", + "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:kahe_traits", + "//willow/crypto:prng_traits", + "//willow/crypto:single_thread_hkdf", "//willow/crypto:vahe_traits", + "//willow/proto/shell:shell_ciphertexts_rust_proto", ], ) @@ -266,7 +292,9 @@ rust_test( "@protobuf//rust:protobuf", "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:prng_traits", + "//willow/crypto:share_cipher", "//willow/crypto:shell_kahe", "//willow/crypto:shell_parameters", "//willow/crypto:shell_vahe", diff --git a/willow/protocol/coordinator.rs b/willow/protocol/coordinator.rs index 398a26a..74d21ab 100644 --- a/willow/protocol/coordinator.rs +++ b/willow/protocol/coordinator.rs @@ -12,17 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -use ahe_traits::{AheBase, PartialDec}; +use ahe_traits::{AheBase, AheKeygen, PartialDec}; +use feldman_vss::{self, derive_keygen_seed, Share}; use kahe_traits::KaheBase; use messages::{ - CoordinatorState, CoordinatorStatus, FinalizedPartialDecryption, PartialDecryptionRequest, + compute_setup_transcript_hash, CommitteeConfig, CoordinatorState, CoordinatorStatus, + DealerSetupInfo, FinalizedPartialDecryption, KeyContribution, PartialDecryptionRequest, PartialDecryptionResponse, RecoveryRequest, RecoveryResponse, SetupContribution, VerifyKeyContributionsRequest, }; +use prng_traits::SecurePrng; +use proto_serialization_traits::ToProto; +use protobuf::Serialize; +use shell_ciphertexts_rust_proto::ShellAhePublicKeyShare; use status::StatusError; +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; use std::rc::Rc; use vahe_traits::{HasVahe, VaheBase}; +pub use messages::MAX_ENCRYPTED_SHARE_BYTES; + /// Coordinator implementation for the multi-decryptor Willow protocol. /// /// The coordinator manages protocol flow and aggregates messages from all @@ -44,8 +54,46 @@ impl HasVahe for Coordinator { } impl Coordinator { + /// Configures `coordinator_state` for a recoverable multi-decryptor committee session. + /// Must be called while `coordinator_state.status == CoordinatorStatus::PreSetup`. + pub fn configure_committee( + &self, + committee_config: CommitteeConfig, + coordinator_state: &mut CoordinatorState, + ) -> Result<(), StatusError> { + if coordinator_state.status != CoordinatorStatus::PreSetup { + return Err(status::failed_precondition( + "configure_committee requires Coordinator in PreSetup state", + )); + } + committee_config.validate()?; + coordinator_state.committee_config = Some(committee_config); + Ok(()) + } + /// Stores setup contributions from all decryptors and creates a request to verify the /// contributions. + /// + /// Note on ZK proof verification and Reputable Decryptor requirement: + /// `Coordinator::handle_setup_submissions` does not verify zero-knowledge `KeyGenProof`s + /// itself—it assembles and returns a [`VerifyKeyContributionsRequest`] specifically for a + /// Reputable Decryptor TEE to verify via + /// `Decryptor::verify_and_aggregate_key_contributions_with_state`. In production + /// (`WillowProtocolImpl::ValidateRuntimeDependencies` in `willow_protocol_impl.cc`), + /// `reputable_decryptor_client_ != nullptr` is hard-required. Accordingly, in recoverable + /// committee mode, `reputable_contributions` must be non-empty (`>= 1` reputable decryptor). + /// + /// In recoverable committee mode (`coordinator_state.committee_config.is_some()`, or when + /// any contribution carries recoverable fields): + /// 1. Requires `!reputable_contributions.is_empty()` (`>= 1` Reputable Decryptor TEE). + /// 2. Validates pairwise-distinct `decryptor_index` in `1..=num_decryptors` across all + /// `non_reputable_contributions` (`len == num_decryptors`). + /// 3. Validates `feldman_commitment.threshold() == committee_config.threshold`. + /// 4. Validates that each dealer's `indexed_encrypted_shares` contains exact keys + /// `{1..=num_decryptors} \ {dealer_index}` with non-empty ciphertexts bounded by + /// `MAX_ENCRYPTED_SHARE_BYTES`. + /// 5. Computes `setup_transcript_hash = compute_setup_transcript_hash(...)` and stores + /// `dealer_setup_info` in `coordinator_state`. pub fn handle_setup_submissions( &self, non_reputable_contributions: Vec>, @@ -56,19 +104,202 @@ impl Coordinator { return Err(status::failed_precondition("Coordinator is not in PreSetup state")); } + let has_recoverable_fields = + non_reputable_contributions.iter().chain(reputable_contributions.iter()).any(|c| { + c.decryptor_index.is_some() + || c.feldman_commitment.is_some() + || !c.indexed_encrypted_shares.is_empty() + }); + + if coordinator_state.committee_config.is_some() || has_recoverable_fields { + let cfg = coordinator_state + .committee_config + .as_ref() + .ok_or_else(|| { + status::failed_precondition( + "CoordinatorState is missing committee_config for recoverable setup contributions", + ) + })? + .clone(); + cfg.validate()?; + + if reputable_contributions.is_empty() { + return Err(status::invalid_argument( + "At least one reputable_contribution is required in recoverable committee mode", + )); + } + if non_reputable_contributions.len() != cfg.num_decryptors as usize { + return Err(status::invalid_argument( + "non_reputable_contributions count must equal committee_config.num_decryptors", + )); + } + for rep in &reputable_contributions { + if rep.decryptor_index.is_some() + || rep.feldman_commitment.is_some() + || !rep.indexed_encrypted_shares.is_empty() + || rep.encrypted_randomness_shares.is_some() + { + return Err(status::invalid_argument( + "reputable_contributions must not carry recoverable share fields", + )); + } + } + + let mut dealer_setup_info = BTreeMap::new(); + let mut dealer_commitments = BTreeMap::new(); + let mut key_contributions_by_index = BTreeMap::new(); + + for contribution in non_reputable_contributions { + if contribution.encrypted_randomness_shares.is_some() { + return Err(status::invalid_argument( + "recoverable SetupContribution must not populate legacy encrypted_randomness_shares", + )); + } + let dealer_index = contribution.decryptor_index.ok_or_else(|| { + status::invalid_argument( + "recoverable SetupContribution is missing decryptor_index", + ) + })?; + if dealer_index.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "SetupContribution decryptor_index exceeds committee_config.num_decryptors", + )); + } + if dealer_setup_info.contains_key(&dealer_index) { + return Err(status::invalid_argument( + "Duplicate decryptor_index in non_reputable_contributions", + )); + } + let feldman_commitment = contribution.feldman_commitment.ok_or_else(|| { + status::invalid_argument( + "recoverable SetupContribution is missing feldman_commitment", + ) + })?; + if feldman_commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "SetupContribution feldman_commitment threshold does not match committee_config.threshold", + )); + } + if contribution.indexed_encrypted_shares.len() != (cfg.num_decryptors - 1) as usize + { + return Err(status::invalid_argument( + "SetupContribution indexed_encrypted_shares count must equal num_decryptors - 1", + )); + } + for peer_raw in 1..=cfg.num_decryptors { + let peer_idx = NonZeroU32::new(peer_raw).ok_or_else(|| { + status::internal("peer index in 1..=num_decryptors must be non-zero") + })?; + if peer_idx == dealer_index { + if contribution.indexed_encrypted_shares.contains_key(&peer_idx) { + return Err(status::invalid_argument( + "SetupContribution indexed_encrypted_shares must not contain self-share", + )); + } + continue; + } + let ct = contribution.indexed_encrypted_shares.get(&peer_idx).ok_or_else( + || { + status::invalid_argument( + "SetupContribution indexed_encrypted_shares is missing peer recipient_index", + ) + }, + )?; + if ct.is_empty() || ct.len() > MAX_ENCRYPTED_SHARE_BYTES { + return Err(status::invalid_argument( + "SetupContribution encrypted share size out of bounds (1..=MAX_ENCRYPTED_SHARE_BYTES)", + )); + } + } + + let public_key_share = contribution.key_contribution.public_key_share.clone(); + dealer_commitments.insert(dealer_index, feldman_commitment.clone()); + dealer_setup_info.insert( + dealer_index, + DealerSetupInfo { + dealer_index, + feldman_commitment, + public_key_share, + encrypted_shares: contribution.indexed_encrypted_shares, + }, + ); + key_contributions_by_index.insert(dealer_index, contribution.key_contribution); + } + + let transcript_hash = compute_setup_transcript_hash( + &cfg.roster_hash, + &cfg.session_context, + cfg.threshold, + cfg.num_decryptors, + &dealer_commitments, + )?; + + let mut all_key_contributions: Vec<_> = + key_contributions_by_index.into_values().collect(); + let mut saved_reputable_contributions = + Vec::with_capacity(reputable_contributions.len()); + for rep in reputable_contributions { + saved_reputable_contributions.push(SetupContribution { + key_contribution: KeyContribution { + public_key_share: rep.key_contribution.public_key_share.clone(), + proof: None, + }, + dp_setup: None, + encrypted_randomness_shares: None, + decryptor_index: None, + feldman_commitment: None, + indexed_encrypted_shares: BTreeMap::new(), + }); + all_key_contributions.push(rep.key_contribution); + } + + for (i, kc_i) in all_key_contributions.iter().enumerate() { + for kc_j in &all_key_contributions[i + 1..] { + if kc_i.public_key_share == kc_j.public_key_share { + return Err(status::invalid_argument( + "Duplicate public_key_share in setup submissions", + )); + } + } + } + + coordinator_state.setup_contributions = if saved_reputable_contributions.is_empty() { + None + } else { + Some(saved_reputable_contributions) + }; + coordinator_state.dealer_setup_info = dealer_setup_info; + coordinator_state.setup_transcript_hash = Some(transcript_hash); + coordinator_state.status = CoordinatorStatus::KeySharesReceived; + + return Ok(VerifyKeyContributionsRequest { key_contributions: all_key_contributions }); + } + let mut all_key_contributions = Vec::new(); + let mut pending_randomness_shares = Vec::new(); // Collect contributions. The coordinator does not verify proofs — that is the // reputable decryptor's responsibility (the coordinator is untrusted). for contribution in non_reputable_contributions.into_iter().chain(reputable_contributions) { // Store encrypted randomness shares from non-reputable decryptors. if let Some(shares) = contribution.encrypted_randomness_shares { - coordinator_state.encrypted_randomness_shares.push(shares); + pending_randomness_shares.push(shares); } all_key_contributions.push(contribution.key_contribution); } + for (i, kc_i) in all_key_contributions.iter().enumerate() { + for kc_j in &all_key_contributions[i + 1..] { + if kc_i.public_key_share == kc_j.public_key_share { + return Err(status::invalid_argument( + "Duplicate public_key_share in setup submissions", + )); + } + } + } + + coordinator_state.encrypted_randomness_shares.extend(pending_randomness_shares); coordinator_state.status = CoordinatorStatus::KeySharesReceived; Ok(VerifyKeyContributionsRequest { key_contributions: all_key_contributions }) @@ -91,12 +322,23 @@ impl Coordinator { let partial_dec_ciphertext = verifier_ciphertext.clone(); + coordinator_state.partial_dec_ciphertext = Some(partial_dec_ciphertext.clone()); + coordinator_state.responded_decryptor_indices.clear(); coordinator_state.status = CoordinatorStatus::AwaitingPartialDecryptions; Ok(PartialDecryptionRequest { partial_dec_ciphertext, aggregation_config: None }) } /// Accumulates partial decryptions from responding decryptors. + /// + /// In recoverable committee mode (`coordinator_state.committee_config.is_some()`): + /// - Validates that each committee `PartialDecryptionResponse` has a pairwise-distinct + /// `decryptor_index` in `1..=num_decryptors`. + /// - Requires at least `threshold` committee responses (`d <= num_decryptors - threshold`); + /// aborts if fewer than `threshold` decryptors responded. + /// - Transitions `coordinator_state.status` to `CoordinatorStatus::OutputReady` when all + /// `num_decryptors` responded (`d == 0`), or to `CoordinatorStatus::AwaitingRecovery` when `0 + /// < d <= num_decryptors - threshold`. pub fn aggregate_partial_decryptions( &self, partial_responses: Vec>, @@ -108,7 +350,90 @@ impl Coordinator { "Coordinator is not in AwaitingPartialDecryptions state", )); } - // Accumulate partial decryptions into a local sum, then update coordinator_state once. + if partial_responses.is_empty() { + return Err(status::invalid_argument("No partial decryptions provided")); + } + + if let Some(cfg) = &coordinator_state.committee_config { + let expected_reputable_count = + coordinator_state.setup_contributions.as_ref().map_or(0, |v| v.len()); + let mut unindexed_count = 0usize; + let mut responded_indices = coordinator_state.responded_decryptor_indices.clone(); + let mut partial_responses_iter = partial_responses.into_iter(); + let first = partial_responses_iter + .next() + .ok_or_else(|| status::invalid_argument("No partial decryptions provided"))?; + if let Some(idx) = first.decryptor_index { + if idx.get() > cfg.num_decryptors + || !coordinator_state.dealer_setup_info.contains_key(&idx) + { + return Err(status::invalid_argument( + "PartialDecryptionResponse decryptor_index is not in committee", + )); + } + if !responded_indices.insert(idx) { + return Err(status::invalid_argument( + "Duplicate decryptor_index in partial_responses", + )); + } + } else if expected_reputable_count == 0 { + return Err(status::invalid_argument( + "PartialDecryptionResponse is missing decryptor_index in committee mode", + )); + } else { + unindexed_count += 1; + } + let mut sum = first.partial_decryption; + + for response in partial_responses_iter { + if let Some(idx) = response.decryptor_index { + if idx.get() > cfg.num_decryptors + || !coordinator_state.dealer_setup_info.contains_key(&idx) + { + return Err(status::invalid_argument( + "PartialDecryptionResponse decryptor_index is not in committee", + )); + } + if !responded_indices.insert(idx) { + return Err(status::invalid_argument( + "Duplicate decryptor_index in partial_responses", + )); + } + } else if expected_reputable_count == 0 { + return Err(status::invalid_argument( + "PartialDecryptionResponse is missing decryptor_index in committee mode", + )); + } else { + unindexed_count += 1; + } + self.vahe + .add_partial_decryptions_in_place(&response.partial_decryption, &mut sum)?; + } + + if unindexed_count != expected_reputable_count { + return Err(status::invalid_argument( + "Count of unindexed reputable PartialDecryptionResponses does not match setup reputable_contributions count", + )); + } + if responded_indices.len() < cfg.threshold as usize { + return Err(status::failed_precondition( + "Insufficient partial decryption responses: fewer than threshold decryptors responded", + )); + } + + let num_decryptors = cfg.num_decryptors as usize; + let num_responded = responded_indices.len(); + coordinator_state.responded_decryptor_indices = responded_indices; + coordinator_state.partial_decryption_sum = Some(sum); + if num_responded == num_decryptors { + coordinator_state.status = CoordinatorStatus::OutputReady; + } else { + coordinator_state.status = CoordinatorStatus::AwaitingRecovery; + } + return Ok(()); + } + + // Legacy / non-recoverable mode (`committee_config.is_none()`): let mut partial_responses_iter = partial_responses.into_iter(); let mut sum = partial_responses_iter .next() @@ -124,27 +449,280 @@ impl Coordinator { Ok(()) } - /// Creates recovery requests for surviving decryptors to decrypt shares of dropped client - /// decryptors. + /// Creates recovery requests keyed by 1-based surviving `decryptor_index` + /// (`responded_decryptor_indices`) to decrypt shares of dropped decryptors. + /// + /// If `coordinator_state.status == CoordinatorStatus::OutputReady` (0 dropouts), returns an + /// empty map. + pub fn create_indexed_recovery_requests( + &self, + coordinator_state: &CoordinatorState, + ) -> Result, StatusError> { + if coordinator_state.status == CoordinatorStatus::OutputReady { + return Ok(BTreeMap::new()); + } + if coordinator_state.status != CoordinatorStatus::AwaitingRecovery { + return Err(status::failed_precondition( + "create_recovery_requests requires Coordinator in AwaitingRecovery or OutputReady state", + )); + } + let cfg = coordinator_state.committee_config.as_ref().ok_or_else(|| { + status::failed_precondition( + "create_recovery_requests requires committee_config in CoordinatorState", + ) + })?; + if coordinator_state.dealer_setup_info.len() != cfg.num_decryptors as usize { + return Err(status::failed_precondition( + "CoordinatorState dealer_setup_info is incomplete", + )); + } + + let mut dropped_indices = Vec::new(); + let mut dealer_commitments = BTreeMap::new(); + for i in 1..=cfg.num_decryptors { + let idx = NonZeroU32::new(i).ok_or_else(|| { + status::internal("committee index in 1..=num_decryptors must be non-zero") + })?; + let info = coordinator_state.dealer_setup_info.get(&idx).ok_or_else(|| { + status::failed_precondition("dealer_setup_info missing committee member") + })?; + dealer_commitments.insert(idx, info.feldman_commitment.clone()); + if !coordinator_state.responded_decryptor_indices.contains(&idx) { + dropped_indices.push(idx); + } + } + + let max_dropouts = (cfg.num_decryptors - cfg.threshold) as usize; + if dropped_indices.is_empty() || dropped_indices.len() > max_dropouts { + return Err(status::failed_precondition( + "Number of dropped decryptors is outside valid recovery range 1..=(num_decryptors - threshold)", + )); + } + + let mut requests = BTreeMap::new(); + for &survivor_idx in &coordinator_state.responded_decryptor_indices { + let mut indexed_encrypted_shares = BTreeMap::new(); + for &dropped_idx in &dropped_indices { + let dealer_info = &coordinator_state.dealer_setup_info[&dropped_idx]; + let ct = dealer_info.encrypted_shares.get(&survivor_idx).ok_or_else(|| { + status::failed_precondition( + "dealer_setup_info missing encrypted share for survivor", + ) + })?; + indexed_encrypted_shares.insert(dropped_idx, ct.clone()); + } + requests.insert( + survivor_idx, + RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares, + dealer_commitments: dealer_commitments.clone(), + }, + ); + } + + Ok(requests) + } + + /// Creates recovery requests for surviving decryptors (ordered by ascending + /// `responded_decryptor_indices`) to decrypt shares of dropped decryptors. /// /// If the vector is empty, there are no dropped decryptors to recover and - /// recover_dropped_decryptors can be called immediately with an empty vector. + /// `recover_dropped_decryptors` can be called immediately with an empty vector. pub fn create_recovery_requests( &self, - _coordinator_state: &mut CoordinatorState, + coordinator_state: &mut CoordinatorState, ) -> Result, StatusError> { - Err(status::unimplemented("Dropout recovery is not yet implemented")) + let indexed = self.create_indexed_recovery_requests(coordinator_state)?; + Ok(indexed.into_values().collect()) } - /// Recovers randomness from dropped decryptors. - /// - /// Uses decrypted shares from survivors to simulate missing partial decryptions. + /// Recovers secret key shares of dropped decryptors from survivor `RecoveryResponse`s + /// and accumulates their simulated partial decryptions into `coordinator_state`: + /// 1. Deduplicates `RecoveryResponse`s by `survivor_index` (must belong to + /// `coordinator_state.responded_decryptor_indices` and never spoof another member's + /// `Share.index`). + /// 2. Filters shares through `feldman_vss::verify_share` against each dropped dealer's setup + /// `FeldmanCommitment` (skipping invalid shares from corrupt survivors so recovery succeeds + /// whenever at least `threshold` valid shares are available). + /// 3. Reconstructs `vss_secret` via `feldman_vss::reconstruct(&commitment_k, + /// &valid_shares[..t])`, re-derives `keygen_seed = derive_keygen_seed(...)`, and regenerates + /// `(recovered_sk_k, recovered_pk_k)` via fast non-ZK `vahe.key_gen(&mut keygen_prng)`. + /// 4. Enforces **Willow Paper Fig. 10 Step 14 (`recovered_pk_k == setup_pk_k`)**, aborting with + /// `PERMISSION_DENIED` if a malicious dealer shared a seed inconsistent with its setup + /// `public_key_share`. Note: because Feldman VSS commits to the Ristretto255 seed scalar + /// `s_k` whereas `public_key_share` is a lattice RLWE public key generated from `HKDF(s_k)`, + /// a malicious dealer can share a valid VSS polynomial for a scalar `s'_k` unrelated to its + /// setup `public_key_share` and then drop out, causing recovery to abort at Step 14. This is + /// a bounded denial-of-service lever (identical to submitting an invalid setup proof in + /// `verify_and_aggregate_key_contributions`) and preserves input privacy under Willow's + /// non-guaranteed-output-delivery threat model. + /// 5. Computes `vahe.partial_decrypt(&ct_1, &recovered_sk_k, &mut fresh_flooding_prng)` using a + /// **fresh** PRNG seed (never reusing `keygen_prng`) and transitions to + /// `CoordinatorStatus::OutputReady`. Ephemeral recovery secrets (`vss_secret`, + /// `keygen_seed`, `flooding_seed`, and `recovered_sk`) are dropped at the end of each + /// dropped-dealer iteration immediately after computing `simulated_pd`. pub fn recover_dropped_decryptors( &self, - _recovery_responses: Vec, - _coordinator_state: &mut CoordinatorState, - ) -> Result<(), StatusError> { - Err(status::unimplemented("Dropout recovery is not yet implemented")) + recovery_responses: Vec, + coordinator_state: &mut CoordinatorState, + ) -> Result<(), StatusError> + where + Vahe: AheKeygen, + Vahe::Rng: SecurePrng, + for<'a> Vahe::PublicKeyShare: ToProto<&'a Vahe, Proto = ShellAhePublicKeyShare>, + { + if coordinator_state.status == CoordinatorStatus::OutputReady + && recovery_responses.is_empty() + { + return Ok(()); + } + if coordinator_state.status != CoordinatorStatus::AwaitingRecovery { + return Err(status::failed_precondition( + "recover_dropped_decryptors requires Coordinator in AwaitingRecovery state", + )); + } + + let cfg = coordinator_state + .committee_config + .as_ref() + .ok_or_else(|| { + status::failed_precondition( + "recover_dropped_decryptors requires committee_config in CoordinatorState", + ) + })? + .clone(); + let ct_1 = coordinator_state.partial_dec_ciphertext.as_ref().ok_or_else(|| { + status::failed_precondition( + "CoordinatorState is missing partial_dec_ciphertext for recovery", + ) + })?; + let mut pd_sum = coordinator_state + .partial_decryption_sum + .as_ref() + .ok_or_else(|| { + status::failed_precondition( + "CoordinatorState is missing partial_decryption_sum for recovery", + ) + })? + .clone(); + + let mut dropped_indices = BTreeSet::new(); + for i in 1..=cfg.num_decryptors { + let idx = NonZeroU32::new(i).ok_or_else(|| { + status::internal("committee index in 1..=num_decryptors must be non-zero") + })?; + if !coordinator_state.responded_decryptor_indices.contains(&idx) { + dropped_indices.insert(idx); + } + } + if dropped_indices.is_empty() + || dropped_indices.len() > (cfg.num_decryptors - cfg.threshold) as usize + { + return Err(status::failed_precondition( + "Number of dropped decryptors is outside valid recovery range", + )); + } + + let mut seen_survivors = BTreeSet::new(); + let mut valid_shares_by_dealer: BTreeMap> = BTreeMap::new(); + + for response in recovery_responses { + if !response.decrypted_shares.is_empty() { + return Err(status::invalid_argument( + "RecoveryResponse must not populate legacy unindexed decrypted_shares", + )); + } + let survivor_idx = response.survivor_index.ok_or_else(|| { + status::invalid_argument("RecoveryResponse is missing survivor_index") + })?; + if !coordinator_state.responded_decryptor_indices.contains(&survivor_idx) { + return Err(status::invalid_argument( + "RecoveryResponse survivor_index is not in responded_decryptor_indices", + )); + } + if !seen_survivors.insert(survivor_idx) { + return Err(status::invalid_argument( + "Duplicate survivor_index in recovery_responses", + )); + } + + for (&dealer_idx, share) in &response.indexed_decrypted_shares { + if !dropped_indices.contains(&dealer_idx) || share.index != survivor_idx { + continue; + } + let dealer_info = + coordinator_state.dealer_setup_info.get(&dealer_idx).ok_or_else(|| { + status::failed_precondition( + "dealer_setup_info missing dropped dealer_index", + ) + })?; + // Filter decrypted shares through `feldman_vss::verify_share`: skip bad shares + // from corrupt survivors rather than aborting when >= threshold valid shares exist. + if feldman_vss::verify_share(&dealer_info.feldman_commitment, share).is_ok() { + valid_shares_by_dealer.entry(dealer_idx).or_default().push(share.clone()); + } + } + } + + let threshold = cfg.threshold as usize; + for &dealer_idx in &dropped_indices { + let dealer_info = + coordinator_state.dealer_setup_info.get(&dealer_idx).ok_or_else(|| { + status::failed_precondition("dealer_setup_info missing dropped dealer_index") + })?; + let valid_shares = valid_shares_by_dealer.get(&dealer_idx).ok_or_else(|| { + status::failed_precondition( + "No valid recovery shares received for dropped decryptor", + ) + })?; + if valid_shares.len() < threshold { + return Err(status::failed_precondition( + "Insufficient valid recovery shares (< threshold) for dropped decryptor", + )); + } + + let vss_secret = feldman_vss::reconstruct( + &dealer_info.feldman_commitment, + &valid_shares[..threshold], + )?; + let keygen_seed = derive_keygen_seed( + &vss_secret, + &cfg.session_context, + &cfg.roster_hash, + dealer_idx, + )?; + let mut keygen_prng = Vahe::Rng::create(&keygen_seed)?; + let (recovered_sk, recovered_pk, _metadata) = self.vahe.key_gen(&mut keygen_prng)?; + + // Willow Paper Fig. 10 Step 14: verify `recovered_pk == setup_pk`. + let recovered_pk_bytes = recovered_pk + .to_proto(self.vahe.as_ref())? + .serialize() + .map_err(|_| status::internal("Failed to serialize recovered PublicKeyShare"))?; + let expected_pk_bytes = dealer_info + .public_key_share + .to_proto(self.vahe.as_ref())? + .serialize() + .map_err(|_| status::internal("Failed to serialize expected PublicKeyShare"))?; + if recovered_pk_bytes != expected_pk_bytes { + return Err(status::permission_denied( + "Regenerated public key share does not match dealer's setup public key share (Willow Fig. 10 Step 14)", + )); + } + + // Compute simulated partial decryption using a FRESH flooding PRNG seed + // (never reusing `keygen_prng`). + let flooding_seed = Vahe::Rng::generate_seed()?; + let mut fresh_flooding_prng = Vahe::Rng::create(&flooding_seed)?; + let simulated_pd = + self.vahe.partial_decrypt(ct_1, &recovered_sk, &mut fresh_flooding_prng)?; + self.vahe.add_partial_decryptions_in_place(&simulated_pd, &mut pd_sum)?; + } + + coordinator_state.partial_decryption_sum = Some(pd_sum); + coordinator_state.status = CoordinatorStatus::OutputReady; + Ok(()) } /// Returns the finalized partial decryption. @@ -159,32 +737,39 @@ impl Coordinator { if coordinator_state.status != CoordinatorStatus::OutputReady { return Err(status::failed_precondition("Coordinator is not in OutputReady state")); } - Ok(FinalizedPartialDecryption { - partial_decryption_sum: coordinator_state - .partial_decryption_sum - .clone() - .ok_or_else(|| status::failed_precondition("partial_decryption_sum is not set"))?, - }) + let partial_decryption_sum = coordinator_state + .partial_decryption_sum + .clone() + .ok_or_else(|| status::failed_precondition("partial_decryption_sum is not set"))?; + coordinator_state.status = CoordinatorStatus::Finished; + Ok(FinalizedPartialDecryption { partial_decryption_sum }) } } #[cfg(test)] mod tests { - use crate::Coordinator; + use crate::{Coordinator, MAX_ENCRYPTED_SHARE_BYTES}; use ahe_traits::AheBase; use decryptor::{Decryptor, DecryptorState}; + use feldman_vss::{SecretScalar, Share}; use googletest::gtest; use googletest::prelude::*; - use messages::{CoordinatorState, CoordinatorStatus, SecretSharingContribution}; + use messages::{ + compute_roster_hash, CommitteeConfig, CoordinatorState, CoordinatorStatus, DecryptorStatus, + RecoveryResponse, SecretSharingContribution, + }; use messages_rust_proto::CoordinatorStatus as CoordinatorStatusProto; use prng_traits::SecurePrng; use proto_serialization_traits::{FromProto, ToProto}; use protobuf::prelude::*; + use share_cipher::{ShareCipher, TinkHpkePrivateKey, TinkHpkePublicKey, TinkHpkeShareCipher}; use shell_kahe::ShellKahe; use shell_parameters::create_shell_ahe_config; use shell_vahe::ShellVahe; use single_thread_hkdf::SingleThreadHkdfPrng; + use std::collections::BTreeMap; + use std::num::NonZeroU32; use std::rc::Rc; use vahe_traits::{Recover, VerifiableEncrypt}; @@ -245,6 +830,39 @@ mod tests { Ok(()) } + #[gtest] + fn coordinator_setup_rejects_duplicate_public_key_shares() -> googletest::Result<()> { + use messages::SetupContribution; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(2)?, CONTEXT_STRING)?); + let decryptor = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut state = DecryptorState::default(); + let contribution = decryptor.create_setup_contribution(&mut state)?; + + let coordinator = Coordinator { vahe }; + let mut coord_state = CoordinatorState::default(); + + // Duplicate public_key_share across non-reputable and reputable contributions must fail + // without advancing CoordinatorState out of PreSetup. + let dup = SetupContribution { + key_contribution: contribution.key_contribution.clone(), + dp_setup: None, + encrypted_randomness_shares: None, + decryptor_index: None, + feldman_commitment: None, + indexed_encrypted_shares: BTreeMap::new(), + }; + let result = + coordinator.handle_setup_submissions(vec![dup], vec![contribution], &mut coord_state); + verify_true!(matches!( + result.map_err(|e| e.code()), + Err(status::StatusErrorCode::INVALID_ARGUMENT) + ))?; + verify_that!(coord_state.status, eq(CoordinatorStatus::PreSetup))?; + + Ok(()) + } + #[gtest] fn aggregate_partial_decryptions_fails_with_no_responses() -> googletest::Result<()> { let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, CONTEXT_STRING)?); @@ -289,6 +907,7 @@ mod tests { let mut contribution1 = contribution1; contribution1.encrypted_randomness_shares = Some(vec![SecretSharingContribution { encrypted_share: b"test_encrypted_share".to_vec(), + recipient_index: None, }]); // Coordinator processes setup (round-trip default PreSetup state first). @@ -419,6 +1038,7 @@ mod tests { status: CoordinatorStatus::PreSetup, encrypted_randomness_shares: vec![vec![SecretSharingContribution { encrypted_share: b"unexpected_share".to_vec(), + recipient_index: None, }]], ..CoordinatorState::default() }; @@ -452,10 +1072,12 @@ mod tests { status: CoordinatorStatus::KeySharesReceived, encrypted_randomness_shares: vec![vec![SecretSharingContribution { encrypted_share: b"share_bytes".to_vec(), + recipient_index: None, }]], dp_noise_component_sum: Some(dp_noise_ct), setup_contributions: Some(vec![contribution]), partial_decryption_sum: None, + ..CoordinatorState::default() }; let proto = state.to_proto(&coordinator)?; let roundtrip = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; @@ -465,4 +1087,746 @@ mod tests { Ok(()) } + + fn build_committee_fixture( + vahe: &Rc, + n: u32, + t: u32, + ) -> Result< + ( + CommitteeConfig, + BTreeMap, + Vec>, + Vec>, + BTreeMap, + Vec>, + Decryptor, + DecryptorState, + messages::SetupContribution, + ), + status::StatusError, + > { + let mut hpke_sks = BTreeMap::new(); + let mut roster_pks = BTreeMap::new(); + let mut raw_roster = BTreeMap::new(); + for i in 1..=n { + let idx = NonZeroU32::new(i).unwrap(); + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + raw_roster.insert(idx, pk.as_bytes().to_vec()); + hpke_sks.insert(idx, sk); + roster_pks.insert(idx, pk); + } + let roster_hash = compute_roster_hash(n, t, &raw_roster)?; + let session_context = CONTEXT_STRING.to_vec(); + let coord_cfg = CommitteeConfig::new( + NonZeroU32::new(1).unwrap(), + n, + t, + roster_hash, + session_context.clone(), + )?; + let mut decryptors = Vec::with_capacity(n as usize); + let mut states = Vec::with_capacity(n as usize); + let mut contributions = Vec::with_capacity(n as usize); + for i in 1..=n { + let idx = NonZeroU32::new(i).unwrap(); + let dec = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut state = DecryptorState::default(); + let cfg = CommitteeConfig::new(idx, n, t, roster_hash, session_context.clone())?; + let contrib = dec.create_recoverable_setup_contribution( + cfg, + &roster_pks, + &hpke_sks[&idx], + &mut state, + )?; + decryptors.push(dec); + states.push(state); + contributions.push(contrib); + } + let rep_decryptor = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut rep_state = DecryptorState::default(); + let rep_contrib = rep_decryptor.create_setup_contribution(&mut rep_state)?; + Ok(( + coord_cfg, + roster_pks, + decryptors, + states, + hpke_sks, + contributions, + rep_decryptor, + rep_state, + rep_contrib, + )) + } + + #[gtest] + fn recoverable_setup_rejects_empty_reputable_contributions() -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(4)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + _decryptors, + _states, + _hpke_sks, + contributions, + _rep_dec, + _rep_state, + _rep_contrib, + ) = build_committee_fixture(&vahe, 3, 2)?; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + + let err = coordinator + .handle_setup_submissions(contributions, vec![], &mut coord_state) + .err() + .expect("expected failure when reputable_contributions is empty"); + verify_that!(err.code(), eq(status::StatusErrorCode::INVALID_ARGUMENT))?; + verify_that!( + err.message(), + contains_substring("At least one reputable_contribution is required") + )?; + Ok(()) + } + + #[gtest] + fn recoverable_committee_zero_dropouts_reaches_output_ready() -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(4)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + decryptors, + mut states, + _hpke_sks, + contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 3, 2)?; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + + // Round-trip PreSetup with committee_config. + let proto = coord_state.to_proto(&coordinator)?; + let mut coord_state = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; + + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + verify_that!(verify_request.key_contributions.len(), eq(4))?; + verify_that!(coord_state.status, eq(CoordinatorStatus::KeySharesReceived))?; + verify_true!(coord_state.setup_transcript_hash.is_some())?; + + // Round-trip KeySharesReceived. + let proto = coord_state.to_proto(&coordinator)?; + let mut coord_state = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; + + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let plaintext = vec![17i64; 8]; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&plaintext, &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let recover_ct = vahe.get_recover_ciphertext(&ciphertext)?; + + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + let proto = coord_state.to_proto(&coordinator)?; + let mut coord_state = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; + + let mut pd_responses = Vec::new(); + pd_responses.push(rep_decryptor.handle_partial_decryption_request( + pd_request.clone(), + None, + &mut rep_state, + )?); + for i in 0..3 { + let mut pd: messages::PartialDecryptionResponse = decryptors[i] + .handle_partial_decryption_request( + pd_request.clone(), + None, + &mut states[i], + )?; + pd.decryptor_index = Some(NonZeroU32::new((i + 1) as u32).unwrap()); + verify_that!(states[i].status, eq(DecryptorStatus::AwaitingRecoveryRequest))?; + pd_responses.push(pd); + } + + coordinator.aggregate_partial_decryptions::( + pd_responses, + None, + &mut coord_state, + )?; + verify_that!(coord_state.status, eq(CoordinatorStatus::OutputReady))?; + verify_true!(coordinator.create_indexed_recovery_requests(&coord_state)?.is_empty())?; + verify_true!(coordinator.create_recovery_requests(&mut coord_state)?.is_empty())?; + + // Round-trip OutputReady. + let proto = coord_state.to_proto(&coordinator)?; + let mut coord_state = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; + + let finalized = coordinator.finalize_partial_decryption(&mut coord_state)?; + let recovered = + vahe.recover(&finalized.partial_decryption_sum, &recover_ct, Some(plaintext.len()))?; + verify_that!(&recovered[..], eq(&plaintext[..]))?; + + Ok(()) + } + + #[gtest] + fn recoverable_committee_dropout_recovery_succeeds_for_up_to_n_minus_t_dropouts( + ) -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(6)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + decryptors, + mut states, + _hpke_sks, + contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 5, 3)?; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let plaintext = vec![99i64; 8]; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&plaintext, &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let recover_ct = vahe.get_recover_ciphertext(&ciphertext)?; + + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + + // Decryptors 2 and 5 drop out (d = 2 <= n - t = 2); survivors are 1, 3, 4. + let survivors = [1u32, 3, 4]; + let mut pd_responses = Vec::new(); + pd_responses.push(rep_decryptor.handle_partial_decryption_request( + pd_request.clone(), + None, + &mut rep_state, + )?); + for &idx_raw in &survivors { + let idx = NonZeroU32::new(idx_raw).unwrap(); + let i = (idx_raw - 1) as usize; + let mut pd: messages::PartialDecryptionResponse = decryptors[i] + .handle_partial_decryption_request( + pd_request.clone(), + None, + &mut states[i], + )?; + pd.decryptor_index = Some(idx); + pd_responses.push(pd); + } + + coordinator.aggregate_partial_decryptions::( + pd_responses, + None, + &mut coord_state, + )?; + verify_that!(coord_state.status, eq(CoordinatorStatus::AwaitingRecovery))?; + + // Round-trip CoordinatorState in AwaitingRecovery. + let proto = coord_state.to_proto(&coordinator)?; + let mut coord_state = CoordinatorState::from_proto(proto.as_view(), &coordinator)?; + verify_that!(coord_state.status, eq(CoordinatorStatus::AwaitingRecovery))?; + + let indexed_requests = coordinator.create_indexed_recovery_requests(&coord_state)?; + verify_that!(indexed_requests.len(), eq(3))?; + + let mut recovery_responses = Vec::new(); + for (survivor_idx, req) in indexed_requests { + let i = (survivor_idx.get() - 1) as usize; + let resp = decryptors[i].handle_recovery_request(req, &mut states[i])?; + verify_that!(states[i].status, eq(DecryptorStatus::Finished))?; + recovery_responses.push(resp); + } + + coordinator.recover_dropped_decryptors(recovery_responses, &mut coord_state)?; + verify_that!(coord_state.status, eq(CoordinatorStatus::OutputReady))?; + + let finalized = coordinator.finalize_partial_decryption(&mut coord_state)?; + let recovered = + vahe.recover(&finalized.partial_decryption_sum, &recover_ct, Some(plaintext.len()))?; + verify_that!(&recovered[..], eq(&plaintext[..]))?; + + Ok(()) + } + + #[gtest] + fn recoverable_committee_aborts_when_more_than_n_minus_t_drop_out() -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(6)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + decryptors, + mut states, + _hpke_sks, + contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 5, 3)?; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&vec![7i64; 8], &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + + // Only 2 committee decryptors respond (< t = 3). + let mut pd_responses = Vec::new(); + pd_responses.push(rep_decryptor.handle_partial_decryption_request( + pd_request.clone(), + None, + &mut rep_state, + )?); + for idx_raw in [1u32, 2] { + let i = (idx_raw - 1) as usize; + let mut pd: messages::PartialDecryptionResponse = decryptors[i] + .handle_partial_decryption_request( + pd_request.clone(), + None, + &mut states[i], + )?; + pd.decryptor_index = Some(NonZeroU32::new(idx_raw).unwrap()); + pd_responses.push(pd); + } + + let err = coordinator + .aggregate_partial_decryptions::(pd_responses, None, &mut coord_state) + .expect_err("expected failure when < t decryptors respond"); + verify_that!(err.code(), eq(status::StatusErrorCode::FAILED_PRECONDITION))?; + verify_that!(err.message(), contains_substring("fewer than threshold"))?; + + Ok(()) + } + + #[gtest] + fn recoverable_committee_tolerates_corrupt_survivor_share_when_t_honest_shares_exist( + ) -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(6)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + decryptors, + mut states, + _hpke_sks, + contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 5, 3)?; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let plaintext = vec![123i64; 8]; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&plaintext, &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let recover_ct = vahe.get_recover_ciphertext(&ciphertext)?; + + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + + // Decryptor 5 drops out; survivors 1, 2, 3, 4 respond (4 >= t = 3). + let mut pd_responses = Vec::new(); + pd_responses.push(rep_decryptor.handle_partial_decryption_request( + pd_request.clone(), + None, + &mut rep_state, + )?); + for idx_raw in [1u32, 2, 3, 4] { + let i = (idx_raw - 1) as usize; + let mut pd: messages::PartialDecryptionResponse = decryptors[i] + .handle_partial_decryption_request( + pd_request.clone(), + None, + &mut states[i], + )?; + pd.decryptor_index = Some(NonZeroU32::new(idx_raw).unwrap()); + pd_responses.push(pd); + } + coordinator.aggregate_partial_decryptions::( + pd_responses, + None, + &mut coord_state, + )?; + + let indexed_requests = coordinator.create_indexed_recovery_requests(&coord_state)?; + let mut recovery_responses = Vec::new(); + for (survivor_idx, req) in indexed_requests { + let i = (survivor_idx.get() - 1) as usize; + let resp = decryptors[i].handle_recovery_request(req, &mut states[i])?; + recovery_responses.push(resp); + } + + let idx3 = NonZeroU32::new(3).unwrap(); + let idx4 = NonZeroU32::new(4).unwrap(); + let idx5 = NonZeroU32::new(5).unwrap(); + + // Corrupt survivor 4's share for dealer 5 (replace scalar with 1). + let bad_share = + Share { index: idx4, value: SecretScalar::from_canonical_bytes(&[1u8; 32])? }; + recovery_responses[3].indexed_decrypted_shares.insert(idx5, bad_share); + + // Also test that if a second survivor (survivor 3) is corrupted leaving only 2 < t = 3 + // valid shares, recovery fails with FAILED_PRECONDITION. + let mut two_corrupt = recovery_responses.clone(); + let bad_share_3 = + Share { index: idx3, value: SecretScalar::from_canonical_bytes(&[2u8; 32])? }; + two_corrupt[2].indexed_decrypted_shares.insert(idx5, bad_share_3); + let mut failed_coord_state = + CoordinatorState::from_proto(coord_state.to_proto(&coordinator)?, &coordinator)?; + let err = coordinator + .recover_dropped_decryptors(two_corrupt, &mut failed_coord_state) + .expect_err("expected failure when < t valid shares remain after verify_share"); + verify_that!(err.code(), eq(status::StatusErrorCode::FAILED_PRECONDITION))?; + + // With 1 corrupt survivor and 3 honest survivors (3 >= t), recovery succeeds! + coordinator.recover_dropped_decryptors(recovery_responses, &mut coord_state)?; + let finalized = coordinator.finalize_partial_decryption(&mut coord_state)?; + let recovered = + vahe.recover(&finalized.partial_decryption_sum, &recover_ct, Some(plaintext.len()))?; + verify_that!(&recovered[..], eq(&plaintext[..]))?; + + Ok(()) + } + + #[gtest] + fn recoverable_committee_detects_malicious_dealer_wrong_pk_share_at_step_14( + ) -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(4)?, CONTEXT_STRING)?); + let ( + cfg, + roster_pks, + decryptors, + mut states, + hpke_sks, + mut contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 3, 2)?; + let idx3 = NonZeroU32::new(3).unwrap(); + + // Malicious dealer 3 generates a second recoverable setup contribution from a different + // Decryptor instance (different VSS secret s'_3), and transplants its consistent + // Feldman commitment + encrypted shares onto contribution[2] while keeping the original + // key_contribution (pk_3). Thus verify_share and reconstruct succeed, but Step 14 + // (recovered_pk_3 == setup_pk_3) must catch the mismatch! + let alt_decryptor = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut alt_state = DecryptorState::default(); + let alt_cfg = + CommitteeConfig::new(idx3, 3, 2, cfg.roster_hash, cfg.session_context.clone())?; + let alt_contrib = alt_decryptor.create_recoverable_setup_contribution( + alt_cfg, + &roster_pks, + &hpke_sks[&idx3], + &mut alt_state, + )?; + contributions[2].feldman_commitment = alt_contrib.feldman_commitment; + contributions[2].indexed_encrypted_shares = alt_contrib.indexed_encrypted_shares; + + let coordinator = Coordinator { vahe: vahe.clone() }; + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&vec![5i64; 8], &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + + // Dealer 3 drops out; survivors 1 and 2 respond. + let mut pd_responses = Vec::new(); + pd_responses.push(rep_decryptor.handle_partial_decryption_request( + pd_request.clone(), + None, + &mut rep_state, + )?); + for idx_raw in [1u32, 2] { + let i = (idx_raw - 1) as usize; + let mut pd: messages::PartialDecryptionResponse = decryptors[i] + .handle_partial_decryption_request( + pd_request.clone(), + None, + &mut states[i], + )?; + pd.decryptor_index = Some(NonZeroU32::new(idx_raw).unwrap()); + pd_responses.push(pd); + } + coordinator.aggregate_partial_decryptions::( + pd_responses, + None, + &mut coord_state, + )?; + + let indexed_requests = coordinator.create_indexed_recovery_requests(&coord_state)?; + let mut recovery_responses = Vec::new(); + for (survivor_idx, req) in indexed_requests { + let i = (survivor_idx.get() - 1) as usize; + let resp = decryptors[i].handle_recovery_request(req, &mut states[i])?; + recovery_responses.push(resp); + } + + let err = coordinator + .recover_dropped_decryptors(recovery_responses, &mut coord_state) + .expect_err("expected Step 14 public-key mismatch error for malicious dealer 3"); + verify_that!(err.code(), eq(status::StatusErrorCode::PERMISSION_DENIED))?; + verify_that!(err.message(), contains_substring("Step 14"))?; + + Ok(()) + } + + #[gtest] + fn recoverable_committee_rejects_invalid_setup_and_duplicate_or_spoofed_responses( + ) -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(4)?, CONTEXT_STRING)?); + let ( + cfg, + _roster_pks, + decryptors, + mut states, + _hpke_sks, + contributions, + rep_decryptor, + mut rep_state, + rep_contrib, + ) = build_committee_fixture(&vahe, 3, 2)?; + let coordinator = Coordinator { vahe: vahe.clone() }; + let idx1 = NonZeroU32::new(1).unwrap(); + let idx2 = NonZeroU32::new(2).unwrap(); + let idx3 = NonZeroU32::new(3).unwrap(); + + let clone_contribs = |src: &[messages::SetupContribution]| -> Result< + Vec>, + status::StatusError, + > { + src.iter() + .map(|c| { + let proto = c.to_proto(&coordinator)?; + messages::SetupContribution::from_proto(proto, &coordinator) + }) + .collect() + }; + let clone_pd = |pd: &messages::PartialDecryptionResponse| { + messages::PartialDecryptionResponse:: { + partial_decryption: pd.partial_decryption.clone(), + dp_ciphertext_contribution: pd.dp_ciphertext_contribution.clone(), + decryptor_index: pd.decryptor_index, + } + }; + + // 1. Wrong number of setup submissions. + let mut state_wrong_count = CoordinatorState::default(); + coordinator.configure_committee(cfg.clone(), &mut state_wrong_count)?; + verify_true!(coordinator + .handle_setup_submissions( + clone_contribs(&contributions[..2])?, + clone_contribs(std::slice::from_ref(&rep_contrib))?, + &mut state_wrong_count, + ) + .is_err())?; + + // 2. Duplicate decryptor_index in setup. + let mut state_dup_idx = CoordinatorState::default(); + coordinator.configure_committee(cfg.clone(), &mut state_dup_idx)?; + let mut dup_contribs = clone_contribs(&contributions)?; + dup_contribs[1].decryptor_index = Some(idx1); + verify_true!(coordinator + .handle_setup_submissions( + dup_contribs, + clone_contribs(std::slice::from_ref(&rep_contrib))?, + &mut state_dup_idx, + ) + .is_err())?; + + // 3. Self-addressed encrypted share in setup. + let mut state_self_share = CoordinatorState::default(); + coordinator.configure_committee(cfg.clone(), &mut state_self_share)?; + let mut self_share_contribs = clone_contribs(&contributions)?; + let ct_removed = self_share_contribs[0].indexed_encrypted_shares.remove(&idx2).unwrap(); + self_share_contribs[0].indexed_encrypted_shares.insert(idx1, ct_removed); + verify_true!(coordinator + .handle_setup_submissions( + self_share_contribs, + clone_contribs(std::slice::from_ref(&rep_contrib))?, + &mut state_self_share, + ) + .is_err())?; + + // 4. Oversized encrypted share (> MAX_ENCRYPTED_SHARE_BYTES). + let mut state_oversized = CoordinatorState::default(); + coordinator.configure_committee(cfg.clone(), &mut state_oversized)?; + let mut oversized_contribs = clone_contribs(&contributions)?; + oversized_contribs[0] + .indexed_encrypted_shares + .insert(idx2, vec![0u8; MAX_ENCRYPTED_SHARE_BYTES + 1]); + verify_true!(coordinator + .handle_setup_submissions( + oversized_contribs, + clone_contribs(std::slice::from_ref(&rep_contrib))?, + &mut state_oversized, + ) + .is_err())?; + + // 5. Valid setup, then test duplicate / missing decryptor_index in partial decryption. + let mut coord_state = CoordinatorState::default(); + coordinator.configure_committee(cfg, &mut coord_state)?; + let verify_request = coordinator.handle_setup_submissions( + contributions, + vec![rep_contrib], + &mut coord_state, + )?; + let public_key = rep_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &rep_state, + 4, + )?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let (ciphertext, _proof) = + vahe.verifiable_encrypt(&vec![3i64; 8], &public_key, b"0123456789ABCDEF", &mut prng)?; + let partial_dec_ct = vahe.get_partial_dec_ciphertext(&ciphertext)?; + let pd_request = + coordinator.prepare_decryption_request(&partial_dec_ct, &mut coord_state)?; + + let pd_rep: messages::PartialDecryptionResponse = rep_decryptor + .handle_partial_decryption_request(pd_request.clone(), None, &mut rep_state)?; + let mut pd1: messages::PartialDecryptionResponse = decryptors[0] + .handle_partial_decryption_request(pd_request.clone(), None, &mut states[0])?; + let mut pd2: messages::PartialDecryptionResponse = decryptors[1] + .handle_partial_decryption_request(pd_request.clone(), None, &mut states[1])?; + + // Missing decryptor_index on a committee response rejected (unindexed_count == 2 != 1). + let mut pd1_missing_idx = clone_pd(&pd1); + pd1_missing_idx.decryptor_index = None; + let mut state_missing_idx = + CoordinatorState::from_proto(coord_state.to_proto(&coordinator)?, &coordinator)?; + verify_true!(coordinator + .aggregate_partial_decryptions::( + vec![clone_pd(&pd_rep), pd1_missing_idx, clone_pd(&pd2)], + None, + &mut state_missing_idx, + ) + .is_err())?; + + // Duplicate decryptor_index rejected. + pd1.decryptor_index = Some(idx1); + pd2.decryptor_index = Some(idx1); + let mut state_dup_pd = + CoordinatorState::from_proto(coord_state.to_proto(&coordinator)?, &coordinator)?; + verify_true!(coordinator + .aggregate_partial_decryptions::( + vec![clone_pd(&pd_rep), clone_pd(&pd1), clone_pd(&pd2)], + None, + &mut state_dup_pd, + ) + .is_err())?; + + // Valid partial decryption with reputable decryptor + decryptors 1 and 2 (decryptor 3 + // dropped). + pd2.decryptor_index = Some(idx2); + coordinator.aggregate_partial_decryptions::( + vec![pd_rep, pd1, pd2], + None, + &mut coord_state, + )?; + + let indexed_requests = coordinator.create_indexed_recovery_requests(&coord_state)?; + let resp1 = decryptors[0] + .handle_recovery_request(indexed_requests[&idx1].clone(), &mut states[0])?; + + // Spoofed RecoveryResponse from dropped decryptor 3 is rejected. + let mut state_spoofed = + CoordinatorState::from_proto(coord_state.to_proto(&coordinator)?, &coordinator)?; + let spoofed_resp = RecoveryResponse { + decrypted_shares: Vec::new(), + survivor_index: Some(idx3), + indexed_decrypted_shares: resp1.indexed_decrypted_shares.clone(), + }; + verify_true!(coordinator + .recover_dropped_decryptors(vec![resp1.clone(), spoofed_resp], &mut state_spoofed) + .is_err())?; + + // Duplicate RecoveryResponse from survivor 1 is rejected. + let mut state_dup_rec = + CoordinatorState::from_proto(coord_state.to_proto(&coordinator)?, &coordinator)?; + verify_true!(coordinator + .recover_dropped_decryptors(vec![resp1.clone(), resp1], &mut state_dup_rec) + .is_err())?; + + Ok(()) + } } diff --git a/willow/protocol/decryptor.rs b/willow/protocol/decryptor.rs index b0bc6b0..6bba2c1 100644 --- a/willow/protocol/decryptor.rs +++ b/willow/protocol/decryptor.rs @@ -14,27 +14,39 @@ use aggregation_config::AggregationConfig; use ahe_traits::{AheKeygen, PartialDec}; +use feldman_vss::{ + self, derive_keygen_seed, FeldmanCommitment, SecretScalar, ROSTER_HASH_BYTE_LEN, +}; use kahe_traits::KaheBase; use messages::{ - DecryptorPublicKey, KeyContribution, PartialDecryptionRequest, PartialDecryptionResponse, + compute_roster_hash, compute_setup_transcript_hash, CommitteeConfig, DecryptorPublicKey, + DecryptorStatus, KeyContribution, PartialDecryptionRequest, PartialDecryptionResponse, RecoveryRequest, RecoveryResponse, SetupContribution, VerifyKeyContributionsRequest, + MAX_ENCRYPTED_SHARE_BYTES, }; use messages_rust_proto::DecryptorState as DecryptorStateProto; use prng_traits::SecurePrng; use proto_serialization_traits::{FromProto, ToProto}; use protobuf::AsView; -use shell_ciphertexts_rust_proto::ShellAheSecretKeyShare; +use share_cipher::{ + ShareCipher, TinkHpkeKeysetWrappingKey, TinkHpkePrivateKey, TinkHpkePublicKey, + TinkHpkeShareCipher, +}; +use shell_ciphertexts_rust_proto::{ShellAhePublicKeyShare, ShellAheSecretKeyShare}; use shell_kahe::ShellKahe; use status::StatusError; use std::cell::RefCell; +use std::collections::BTreeMap; +use std::num::NonZeroU32; use std::rc::Rc; use vahe_traits::{HasVahe, KeyGenVerify, VaheBase, VerifiableKeyGen}; /// Implementation of Decryptor directly exposing VAHE types. /// /// This struct supports multi-decryptor committee mode via inherent methods -/// (`create_setup_contribution`, `handle_partial_decryption_request`, -/// `verify_and_aggregate_key_contributions`, `handle_recovery_request`). +/// (`create_setup_contribution`, `create_recoverable_setup_contribution`, +/// `handle_partial_decryption_request`, `verify_and_aggregate_key_contributions`, +/// `handle_recovery_request`). pub struct Decryptor { pub vahe: Rc, pub prng: RefCell, @@ -57,13 +69,90 @@ impl Decryptor { pub struct DecryptorState { pub sk_share: Option, + /// Own public key share generated during setup, used to verify that + /// `VerifyKeyContributionsRequest` includes this decryptor's key share. + pub pk_share: Option, pub kahe: Option>, pub aggregation_config: Option, + pub status: DecryptorStatus, + pub committee_config: Option, + /// In-memory Tink HPKE private key handle for decrypting peer shares during recovery. + /// Cleared immediately when `handle_recovery_request` is invoked. + pub hpke_private_key: Option, + /// Serialized Tink `EncryptedKeyset` bytes produced exclusively via + /// `TinkHpkePrivateKey::to_encrypted_keyset` (or restored via `FromProto`). + /// Kept private to prevent accidental storage of unencrypted keysets. + hpke_private_keyset: Option>, + /// Broadcast `FeldmanCommitment`s indexed by 1-based `dealer_index`, bound to + /// `setup_transcript_hash`. + pub dealer_commitments: BTreeMap, + /// 32-byte SHA-256 setup transcript hash binding `(session_context, roster_hash, + /// dealer_commitments)`. + pub setup_transcript_hash: Option<[u8; ROSTER_HASH_BYTE_LEN]>, +} + +impl DecryptorState { + /// Encrypts `private_key` under `wrapping_key` and `associated_data` using Tink + /// `EncryptedKeysetFormat` (`TinkHpkePrivateKey::to_encrypted_keyset`), stores the + /// resulting ciphertext in `self.hpke_private_keyset`, and caches the in-memory + /// handle in `self.hpke_private_key`. + pub fn set_encrypted_hpke_private_key( + &mut self, + private_key: &TinkHpkePrivateKey, + wrapping_key: &TinkHpkeKeysetWrappingKey, + associated_data: &[u8], + ) -> status::Status { + let encrypted = private_key.to_encrypted_keyset(wrapping_key, associated_data)?; + self.hpke_private_keyset = Some(encrypted); + self.hpke_private_key = Some(private_key.clone()); + Ok(()) + } + + /// Decrypts `self.hpke_private_keyset` under `wrapping_key` and `associated_data` + /// (`TinkHpkePrivateKey::from_encrypted_keyset`) and caches the resulting handle in + /// `self.hpke_private_key`. + pub fn restore_encrypted_hpke_private_key( + &mut self, + wrapping_key: &TinkHpkeKeysetWrappingKey, + associated_data: &[u8], + ) -> Result<&TinkHpkePrivateKey, StatusError> { + let encrypted = self.hpke_private_keyset.as_deref().ok_or_else(|| { + status::failed_precondition("DecryptorState does not contain an encrypted HPKE keyset") + })?; + let key = + TinkHpkePrivateKey::from_encrypted_keyset(encrypted, wrapping_key, associated_data)?; + self.hpke_private_key = Some(key); + self.hpke_private_key.as_ref().ok_or_else(|| { + status::internal("hpke_private_key missing after successful restoration") + }) + } + + /// Returns a read-only slice of the Tink `EncryptedKeyset` bytes, if present. + pub fn encrypted_hpke_private_keyset(&self) -> Option<&[u8]> { + self.hpke_private_keyset.as_deref() + } + + /// Clears the in-memory `hpke_private_key` handle and drops `hpke_private_keyset`. + pub fn clear_hpke_private_key(&mut self) { + self.hpke_private_key = None; + self.hpke_private_keyset = None; + } } impl Default for DecryptorState { fn default() -> Self { - Self { sk_share: None, kahe: None, aggregation_config: None } + Self { + sk_share: None, + pk_share: None, + kahe: None, + aggregation_config: None, + status: DecryptorStatus::PreSetup, + committee_config: None, + hpke_private_key: None, + hpke_private_keyset: None, + dealer_commitments: BTreeMap::new(), + setup_transcript_hash: None, + } } } @@ -72,17 +161,34 @@ where C: HasVahe, Vahe: VaheBase + 'a, Vahe::SecretKeyShare: ToProto<&'a Vahe, Proto = ShellAheSecretKeyShare>, + Vahe::PublicKeyShare: ToProto<&'a Vahe, Proto = ShellAhePublicKeyShare>, { type Proto = DecryptorStateProto; fn to_proto(&self, context: &'a C) -> Result { let mut proto = DecryptorStateProto::new(); + proto.set_status(self.status.to_proto(())?); if let Some(sk) = &self.sk_share { proto.set_sk_share(sk.to_proto(context.vahe())?); } + if let Some(pk) = &self.pk_share { + proto.set_pk_share(pk.to_proto(context.vahe())?); + } if let Some(config) = &self.aggregation_config { proto.set_aggregation_config(config.to_proto(())?); } + if let Some(committee_config) = &self.committee_config { + proto.set_committee_config(committee_config.to_proto(())?); + } + if let Some(hpke_sk) = &self.hpke_private_keyset { + proto.set_hpke_private_keyset(hpke_sk.as_slice()); + } + for (&dealer_idx, commitment) in &self.dealer_commitments { + proto.dealer_commitments_mut().insert(dealer_idx.get(), commitment.to_proto(())?); + } + if let Some(transcript_hash) = &self.setup_transcript_hash { + proto.set_setup_transcript_hash(transcript_hash.as_slice()); + } Ok(proto) } } @@ -92,6 +198,7 @@ where C: HasVahe, Vahe: VaheBase + 'a, Vahe::SecretKeyShare: FromProto<&'a Vahe, Proto = ShellAheSecretKeyShare>, + Vahe::PublicKeyShare: FromProto<&'a Vahe, Proto = ShellAhePublicKeyShare>, { type Proto = DecryptorStateProto; @@ -105,6 +212,114 @@ where } else { None }; + let pk_share = if proto.has_pk_share() { + Some(Vahe::PublicKeyShare::from_proto(proto.pk_share(), context.vahe())?) + } else { + None + }; + let committee_config = if proto.has_committee_config() { + Some(CommitteeConfig::from_proto(proto.committee_config(), ())?) + } else { + None + }; + let hpke_private_keyset = if proto.hpke_private_keyset().is_empty() { + None + } else { + Some(proto.hpke_private_keyset().to_vec()) + }; + let mut dealer_commitments = BTreeMap::new(); + for (raw_idx, comm_view) in proto.dealer_commitments().iter() { + let dealer_idx = NonZeroU32::new(raw_idx).ok_or_else(|| { + status::invalid_argument("DecryptorState dealer_commitments key must be non-zero") + })?; + if let Some(cfg) = &committee_config { + if dealer_idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "DecryptorState dealer_commitments index exceeds num_decryptors", + )); + } + } + let commitment = FeldmanCommitment::from_proto(comm_view, ())?; + if let Some(cfg) = &committee_config { + if commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "DecryptorState dealer commitment threshold does not match CommitteeConfig", + )); + } + } + dealer_commitments.insert(dealer_idx, commitment); + } + let setup_transcript_hash = if proto.setup_transcript_hash().is_empty() { + None + } else { + let hash: [u8; ROSTER_HASH_BYTE_LEN] = + proto.setup_transcript_hash().try_into().map_err(|_| { + status::invalid_argument( + "DecryptorState setup_transcript_hash must be 32 bytes", + ) + })?; + if let Some(cfg) = &committee_config { + if dealer_commitments.len() == cfg.num_decryptors as usize { + let expected = compute_setup_transcript_hash( + &cfg.roster_hash, + &cfg.session_context, + cfg.threshold, + cfg.num_decryptors, + &dealer_commitments, + )?; + if expected != hash { + return Err(status::invalid_argument( + "DecryptorState setup_transcript_hash does not match dealer_commitments", + )); + } + } else { + return Err(status::invalid_argument( + "DecryptorState with setup_transcript_hash must have all num_decryptors dealer_commitments", + )); + } + } + Some(hash) + }; + // Backward compatibility for legacy single-decryptor `DecryptorStateProto` + // messages (`committee_config.is_none() && hpke_private_keyset.is_none()`) + // where proto3 `status` defaults to `DECRYPTOR_STATUS_UNSPECIFIED` (0): + // infer `AwaitingDecryptionRequest` when `sk_share` is present and `PreSetup` + // when `sk_share` is absent. Recoverable committee states (`committee_config` + // or `hpke_private_keyset` present) strictly reject `Unspecified` (`B7`). + let status = match proto.status() { + messages_rust_proto::DecryptorStatus::Unspecified => { + if committee_config.is_some() + || hpke_private_keyset.is_some() + || !dealer_commitments.is_empty() + || setup_transcript_hash.is_some() + { + return Err(status::invalid_argument( + "DecryptorStatus is unspecified for recoverable committee state", + )); + } + if sk_share.is_some() { + DecryptorStatus::AwaitingDecryptionRequest + } else { + DecryptorStatus::PreSetup + } + } + raw_status => DecryptorStatus::from_proto(raw_status, ())?, + }; + if (status == DecryptorStatus::PreSetup || status == DecryptorStatus::Finished) + && (sk_share.is_some() || pk_share.is_some()) + { + return Err(status::invalid_argument( + "DecryptorState in PreSetup or Finished status must not have sk_share or pk_share", + )); + } + if (status == DecryptorStatus::AwaitingDecryptionRequest + || status == DecryptorStatus::AwaitingRecoveryRequest) + && sk_share.is_none() + { + return Err(status::invalid_argument( + "DecryptorState in active status is missing sk_share", + )); + } let aggregation_config = if proto.has_aggregation_config() { Some(AggregationConfig::from_proto(proto.aggregation_config(), ())?) } else { @@ -117,7 +332,18 @@ where } else { None }; - Ok(DecryptorState { sk_share, kahe, aggregation_config }) + Ok(DecryptorState { + sk_share, + pk_share, + kahe, + aggregation_config, + status, + committee_config, + hpke_private_key: None, + hpke_private_keyset, + dealer_commitments, + setup_transcript_hash, + }) } } @@ -125,10 +351,11 @@ impl Decryptor where Vahe: VaheBase + VerifiableKeyGen + KeyGenVerify + PartialDec + AheKeygen, { - /// Creates a public key share, a ZK proof of knowledge of the secret key, - /// and encrypted shares of the randomness used for key generation. + /// Creates a public key share and a ZK proof of knowledge of the secret key + /// without secret-sharing the key generation seed. /// - /// The randomness shares are encrypted for other committee members. + /// Preserved with its exact signature and PRNG draw sequence for single-decryptor + /// and reputable-decryptor callers. pub fn create_setup_contribution( &self, decryptor_state: &mut DecryptorState, @@ -137,6 +364,184 @@ where self.vahe.verifiable_key_gen(&mut self.prng.borrow_mut())?; decryptor_state.sk_share = Some(sk_share); + decryptor_state.pk_share = Some(pk_share.clone()); + decryptor_state.status = DecryptorStatus::AwaitingDecryptionRequest; + + Ok(SetupContribution { + key_contribution: KeyContribution { + public_key_share: pk_share, + proof: Some(key_gen_proof), + }, + dp_setup: None, + encrypted_randomness_shares: None, + decryptor_index: decryptor_state.committee_config.as_ref().map(|c| c.my_index), + feldman_commitment: None, + indexed_encrypted_shares: BTreeMap::new(), + }) + } + + /// Deterministically regenerates `(sk_share, pk_share)` into `decryptor_state` + /// from `self.prng` via `self.vahe.key_gen` without computing the zero-knowledge + /// proof (`verifiable_key_gen`). + /// + /// Because `ShellVahe::key_gen` and `ShellVahe::verifiable_key_gen` perform the + /// exact same PRNG draws (`ahe::generate_secret_key` + `ahe::generate_public_key_share`) + /// while `rlwe_zk.prove` does not consume `self.prng`, seed-only TEE actors + /// can call `regenerate_state` on a freshly seeded `Decryptor` before + /// `verify_and_aggregate_key_contributions_with_state` or + /// `handle_partial_decryption_request` to reconstruct `decryptor_state.pk_share` + /// and `decryptor_state.sk_share` without re-running the ZK prover. + pub fn regenerate_state( + &self, + decryptor_state: &mut DecryptorState, + ) -> Result<(), StatusError> { + let (sk_share, pk_share, _) = self.vahe.key_gen(&mut self.prng.borrow_mut())?; + decryptor_state.sk_share = Some(sk_share); + decryptor_state.pk_share = Some(pk_share); + decryptor_state.status = DecryptorStatus::AwaitingDecryptionRequest; + Ok(()) + } + + /// Creates a recoverable multi-decryptor setup contribution for a committee member: + /// 1. Validates `committee_config` and verifies that `roster_public_keys` forms a complete + /// 1-based roster `1..=num_decryptors` whose `compute_roster_hash` matches + /// `committee_config.roster_hash`, and whose entry at `committee_config.my_index` matches + /// `hpke_private_key.public_key()`. + /// 2. Samples a uniform non-zero Ristretto255 `vss_secret` from `self.prng`. + /// 3. Derives a 64-byte `keygen_seed = derive_keygen_seed(&vss_secret, &session_context, + /// &roster_hash, my_index)` and runs `self.vahe.verifiable_key_gen` on an isolated + /// `Vahe::Rng::create(&keygen_seed)` instance (so `self.prng` is completely decoupled from + /// `verifiable_key_gen` draw counts). + /// 4. Splits `vss_secret` into `(feldman_commitment, shares)` via `feldman_vss::split` and + /// HPKE-encrypts each peer share (`share.index != my_index`) under that peer's + /// `TinkHpkePublicKey` with context-bound `(session_context, roster_hash, my_index, + /// recipient_index)`. + /// + /// Note on state persistence: this method caches the in-memory `hpke_private_key` handle on + /// `decryptor_state`. Callers that serialize `decryptor_state` across process restarts via + /// `to_proto` must call [`DecryptorState::set_encrypted_hpke_private_key`] before + /// serializing (and [`DecryptorState::restore_encrypted_hpke_private_key`] after + /// `from_proto`) so the HPKE private key is wrapped under a Tink AEAD keyset in + /// `DecryptorStateProto.hpke_private_keyset`. + /// + /// # Roster Attestation Trust Boundary (Audit A3 item 4) + /// + /// This method verifies that `compute_roster_hash(num_decryptors, threshold, + /// roster_public_keys)` matches `committee_config.roster_hash` and that + /// `roster_public_keys[my_index]` matches `hpke_private_key.public_key()`. Callers **MUST** + /// obtain `roster_public_keys` from an authenticated or attested roster source so an untrusted + /// Coordinator cannot populate the peer entries of `roster_public_keys` with Sybil HPKE keys + /// under its own control. + pub fn create_recoverable_setup_contribution( + &self, + committee_config: CommitteeConfig, + roster_public_keys: &BTreeMap, + hpke_private_key: &TinkHpkePrivateKey, + decryptor_state: &mut DecryptorState, + ) -> Result, StatusError> + where + Vahe::Rng: SecurePrng, + { + if decryptor_state.status != DecryptorStatus::PreSetup || decryptor_state.sk_share.is_some() + { + return Err(status::failed_precondition( + "create_recoverable_setup_contribution requires DecryptorState in PreSetup status", + )); + } + if let Some(existing_cfg) = &decryptor_state.committee_config { + if existing_cfg != &committee_config { + return Err(status::failed_precondition( + "DecryptorState already has a different immutable CommitteeConfig", + )); + } + } + committee_config.validate()?; + + if roster_public_keys.len() != committee_config.num_decryptors as usize { + return Err(status::invalid_argument( + "roster_public_keys length must equal committee_config.num_decryptors", + )); + } + let mut raw_roster = BTreeMap::new(); + for i in 1..=committee_config.num_decryptors { + let idx = NonZeroU32::new(i).ok_or_else(|| { + status::internal("committee index in 1..=num_decryptors must be non-zero") + })?; + let pk = roster_public_keys.get(&idx).ok_or_else(|| { + status::invalid_argument( + "roster_public_keys must contain every index in 1..=num_decryptors", + ) + })?; + TinkHpkeShareCipher::validate_public_key(pk)?; + raw_roster.insert(idx, pk.as_bytes().to_vec()); + } + + let own_expected_pk = hpke_private_key.public_key()?; + let own_roster_pk = + roster_public_keys.get(&committee_config.my_index).ok_or_else(|| { + status::invalid_argument("roster_public_keys is missing committee_config.my_index") + })?; + if &own_expected_pk != own_roster_pk { + return Err(status::invalid_argument( + "hpke_private_key public key does not match roster_public_keys[my_index]", + )); + } + + let computed_roster_hash = compute_roster_hash( + committee_config.num_decryptors, + committee_config.threshold, + &raw_roster, + )?; + if computed_roster_hash != committee_config.roster_hash { + return Err(status::invalid_argument( + "computed roster hash does not match committee_config.roster_hash", + )); + } + + // Sample `vss_secret` and split polynomial using `self.prng`, while running + // `verifiable_key_gen` on an isolated PRNG seeded from `derive_keygen_seed`. + let vss_secret = SecretScalar::random(&mut *self.prng.borrow_mut())?; + let keygen_seed = derive_keygen_seed( + &vss_secret, + &committee_config.session_context, + &committee_config.roster_hash, + committee_config.my_index, + )?; + let mut keygen_prng = Vahe::Rng::create(&keygen_seed)?; + let (sk_share, pk_share, key_gen_proof) = self.vahe.verifiable_key_gen(&mut keygen_prng)?; + + let (feldman_commitment, shares) = feldman_vss::split( + &vss_secret, + committee_config.threshold, + committee_config.num_decryptors, + &mut *self.prng.borrow_mut(), + )?; + + let mut indexed_encrypted_shares = BTreeMap::new(); + for share in &shares { + if share.index == committee_config.my_index { + continue; + } + let peer_pk = roster_public_keys.get(&share.index).ok_or_else(|| { + status::internal("peer public key missing for validated share index") + })?; + let ciphertext = TinkHpkeShareCipher::encrypt_share( + peer_pk, + share, + &committee_config.session_context, + &committee_config.roster_hash, + committee_config.my_index, + )?; + indexed_encrypted_shares.insert(share.index, ciphertext); + } + + let my_index = committee_config.my_index; + decryptor_state.sk_share = Some(sk_share); + decryptor_state.pk_share = Some(pk_share.clone()); + decryptor_state.committee_config = Some(committee_config); + decryptor_state.hpke_private_key = Some(hpke_private_key.clone()); + decryptor_state.dealer_commitments.insert(my_index, feldman_commitment.clone()); + decryptor_state.status = DecryptorStatus::AwaitingDecryptionRequest; Ok(SetupContribution { key_contribution: KeyContribution { @@ -145,19 +550,94 @@ where }, dp_setup: None, encrypted_randomness_shares: None, + decryptor_index: Some(my_index), + feldman_commitment: Some(feldman_commitment), + indexed_encrypted_shares, }) } + /// Validates and binds all committee members' broadcast `FeldmanCommitment`s into + /// `decryptor_state`, verifying that: + /// 1. Every `dealer_index` is in `1..=committee_config.num_decryptors` (`num_decryptors` + /// entries total) and every commitment has `threshold() == committee_config.threshold`. + /// 2. If this decryptor generated its own `FeldmanCommitment` during setup, the entry at + /// `committee_config.my_index` is present and identical to the commitment it broadcast + /// (preventing Coordinator equivocation on this decryptor's commitment). + /// 3. Computes and locks `setup_transcript_hash = compute_setup_transcript_hash(...)` + /// (rejecting any attempt to rebind a different commitment transcript). + pub fn bind_setup_commitments( + &self, + dealer_commitments: BTreeMap, + decryptor_state: &mut DecryptorState, + ) -> Result<[u8; ROSTER_HASH_BYTE_LEN], StatusError> { + let cfg = decryptor_state.committee_config.as_ref().ok_or_else(|| { + status::failed_precondition( + "bind_setup_commitments requires committee_config in DecryptorState", + ) + })?; + if dealer_commitments.len() != cfg.num_decryptors as usize { + return Err(status::invalid_argument( + "dealer_commitments must contain exactly num_decryptors entries (1..=num_decryptors)", + )); + } + for (&dealer_idx, commitment) in &dealer_commitments { + if dealer_idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "dealer_commitments index exceeds committee_config.num_decryptors", + )); + } + if commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "dealer commitment threshold does not match committee_config.threshold", + )); + } + } + // If this decryptor already recorded its own commitment at `my_index` (or a previous + // full commitment map), verify that our own commitment at `my_index` is unchanged. + if let Some(own_commitment) = decryptor_state.dealer_commitments.get(&cfg.my_index) { + let provided_own = dealer_commitments.get(&cfg.my_index).ok_or_else(|| { + status::invalid_argument( + "dealer_commitments is missing this decryptor's own commitment at my_index", + ) + })?; + if provided_own != own_commitment { + return Err(status::invalid_argument( + "dealer_commitments entry at my_index does not match own setup commitment", + )); + } + } + + let transcript_hash = compute_setup_transcript_hash( + &cfg.roster_hash, + &cfg.session_context, + cfg.threshold, + cfg.num_decryptors, + &dealer_commitments, + )?; + if let Some(existing_hash) = &decryptor_state.setup_transcript_hash { + if existing_hash != &transcript_hash { + return Err(status::failed_precondition( + "DecryptorState already bound a different setup_transcript_hash", + )); + } + } + decryptor_state.dealer_commitments = dealer_commitments; + decryptor_state.setup_transcript_hash = Some(transcript_hash); + Ok(transcript_hash) + } + /// Verifies the ZK proofs of knowledge of the secret key for all public key - /// shares, and returns the aggregated public key. Calling code should sign - /// the aggregated public key for the aggregation. + /// shares, enforces that all `public_key_share` entries are pairwise distinct, + /// and returns the aggregated public key. Calling code should sign the + /// aggregated public key for the aggregation. /// - /// Note: This method fails if any single contribution has an invalid proof, - /// aborting the entire key generation. This is acceptable because the - /// protocol does not guarantee output delivery against malicious decryptors - /// or a malicious coordinator — a malicious participant could equally cause - /// failure by refusing to provide partial decryptions and secret-sharing an - /// invalid state in the setup contribution. + /// Note: This method fails if any single contribution has an invalid proof or + /// duplicates another contribution's `public_key_share`, aborting the entire + /// key generation. This is acceptable because the protocol does not guarantee + /// output delivery against malicious decryptors or a malicious coordinator — + /// a malicious participant could equally cause failure by refusing to provide + /// partial decryptions and secret-sharing an invalid state in the setup + /// contribution. pub fn verify_and_aggregate_key_contributions( &self, request: VerifyKeyContributionsRequest, @@ -166,6 +646,19 @@ where return Err(status::invalid_argument("key_contributions list is empty")); } + // Enforce pairwise distinct public_key_shares so a malicious coordinator + // cannot duplicate an honest or corrupt contribution to skew key weights + // or cause decryption failure. + for (i, kc_i) in request.key_contributions.iter().enumerate() { + for kc_j in &request.key_contributions[i + 1..] { + if kc_i.public_key_share == kc_j.public_key_share { + return Err(status::invalid_argument( + "Duplicate public_key_share in VerifyKeyContributionsRequest", + )); + } + } + } + // Verify all proofs. for key_contribution in &request.key_contributions { let proof = key_contribution.proof.as_ref().ok_or_else(|| { @@ -183,14 +676,55 @@ where self.vahe.aggregate_public_key_shares(public_key_shares.into_iter()) } + /// Stateful counterpart to [`Self::verify_and_aggregate_key_contributions`] for + /// Reputable Decryptors (TEE) that additionally verifies: + /// 1. `min_contributors >= 1` and `request.key_contributions.len() >= min_contributors`; + /// 2. `decryptor_state.pk_share` is present (`create_setup_contribution` or `regenerate_state` + /// has been called) and appears in `request.key_contributions` (preventing an untrusted + /// Coordinator from omitting or substituting the Reputable Decryptor's own key share); + /// 3. All `public_key_share` entries are pairwise distinct and all ZK proofs verify. + pub fn verify_and_aggregate_key_contributions_with_state( + &self, + request: VerifyKeyContributionsRequest, + decryptor_state: &DecryptorState, + min_contributors: usize, + ) -> Result, StatusError> { + if min_contributors == 0 { + return Err(status::invalid_argument("min_contributors must be at least 1")); + } + if request.key_contributions.len() < min_contributors { + return Err(status::invalid_argument( + "VerifyKeyContributionsRequest has fewer contributions than min_contributors", + )); + } + let own_pk_share = decryptor_state.pk_share.as_ref().ok_or_else(|| { + status::failed_precondition( + "DecryptorState does not contain own pk_share (call create_setup_contribution or regenerate_state first)", + ) + })?; + if !request.key_contributions.iter().any(|kc| &kc.public_key_share == own_pk_share) { + return Err(status::invalid_argument( + "VerifyKeyContributionsRequest does not include this decryptor's own public_key_share", + )); + } + self.verify_and_aggregate_key_contributions(request) + } + /// Handles a partial decryption request received from the Server. Returns a - /// partial decryption to the Server. + /// partial decryption to the Server and transitions `decryptor_state.status` + /// from `AwaitingDecryptionRequest` to `AwaitingRecoveryRequest` (enforcing + /// one-shot partial decryption per committee session). pub fn handle_partial_decryption_request( &self, partial_decryption_request: PartialDecryptionRequest, _kahe: Option<&Kahe>, decryptor_state: &mut DecryptorState, ) -> Result, StatusError> { + if decryptor_state.status != DecryptorStatus::AwaitingDecryptionRequest { + return Err(status::failed_precondition( + "handle_partial_decryption_request requires DecryptorStatus::AwaitingDecryptionRequest", + )); + } if let Some(config) = &partial_decryption_request.aggregation_config { if decryptor_state.kahe.is_none() { use shell_parameters::create_shell_configs; @@ -209,20 +743,159 @@ where sk_share, &mut self.prng.borrow_mut(), )?; - Ok(PartialDecryptionResponse { partial_decryption: pd, dp_ciphertext_contribution: None }) + // Enforce one-shot partial decryption transition to `AwaitingRecoveryRequest` for + // recoverable committee sessions (`committee_config.is_some()`), while preserving + // `AwaitingDecryptionRequest` for legacy/single-decryptor states + // (`committee_config.is_none()`). + if decryptor_state.committee_config.is_some() { + decryptor_state.status = DecryptorStatus::AwaitingRecoveryRequest; + } + Ok(PartialDecryptionResponse { + partial_decryption: pd, + dp_ciphertext_contribution: None, + decryptor_index: decryptor_state.committee_config.as_ref().map(|c| c.my_index), + }) } - /// Handles a request to decrypt shares of dropped decryptors. + /// Handles a one-shot request to decrypt Feldman VSS shares of dropped decryptors. /// - /// The decryptor should verify they are not being asked to decrypt more than - /// the allowed threshold of shares. + /// Enforces: + /// 1. `decryptor_state.status == DecryptorStatus::AwaitingRecoveryRequest` (i.e., this + /// surviving decryptor has already completed its own one-shot + /// `handle_partial_decryption_request`), and **immediately** burns the state to + /// `DecryptorStatus::Finished` and clears the HPKE private key before any fallible check + /// (`C1` / `A2` one-shot guarantee). + /// 2. At least 1 and at most `num_decryptors - threshold` (`n - t`) shares are requested. Note + /// on Threat Model (`A2` split-view bound): each survivor locally enforces `|requested| <= n + /// - t` and one-shot state burn. In the hybrid model where the coordinator or an attested + /// reputable decryptor broadcast-binds a single recovery set, at most `n - t` dropped + /// decryptors can be reconstructed globally. In a model with an arbitrary Byzantine + /// coordinator and no shared recovery-set transcript across survivors, preventing disjoint + /// survivor subsets from reconstructing more than `n - t` secrets requires `2 * threshold > + /// num_decryptors + max_corrupt`. + /// 3. The broadcast `FeldmanCommitment`s are bound to `setup_transcript_hash` (including + /// verifying that this decryptor's own commitment at `my_index` matches setup). + /// 4. Each requested share is for a distinct `dealer_index != my_index` in + /// `1..=num_decryptors`, addressed to `recipient_index == my_index`, has ciphertext length + /// in `1..=MAX_ENCRYPTED_SHARE_BYTES`, decrypts under HPKE with context-bound + /// `(session_context, roster_hash, dealer_index, my_index)`, and passes + /// `feldman_vss::verify_share(&dealer_commitment, &share)` before release (`#75`). + /// + /// # Caller Persistence Contract (Crash / Rollback Defense — Audit A4 item 2a) + /// + /// Before transmitting the returned `RecoveryResponse` (or any error response) over the + /// network, callers that persist `DecryptorState` across restarts **MUST** synchronously + /// serialize and durably commit the updated `decryptor_state` + /// (`status == DecryptorStatus::Finished`, `sk_share == None`, `hpke_private_keyset == None`) + /// to non-volatile storage (or advance a hardware monotonic counter). Persisting the burned + /// `Finished` state *before* releasing any decrypted share prevents a crash-and-restart or + /// storage-rollback attack from re-running `handle_recovery_request` on a pre-burn snapshot. pub fn handle_recovery_request( &self, - _recovery_request: RecoveryRequest, - _decryptor_state: &mut DecryptorState, + recovery_request: RecoveryRequest, + decryptor_state: &mut DecryptorState, ) -> Result { - // Secret sharing / dropout recovery is not yet implemented. - Err(status::unimplemented("Dropout recovery is not yet implemented")) + if decryptor_state.status != DecryptorStatus::AwaitingRecoveryRequest { + return Err(status::failed_precondition( + "handle_recovery_request requires AwaitingRecoveryRequest status (call handle_partial_decryption_request first)", + )); + } + + // Immediately transition to `Finished`, drop `sk_share`, and take and clear + // the HPKE private key so that even a failed recovery call burns the one-shot + // recovery capability and leaves no secret key material in `decryptor_state`. + decryptor_state.status = DecryptorStatus::Finished; + decryptor_state.sk_share.take(); + decryptor_state.pk_share.take(); + let hpke_private_key = decryptor_state.hpke_private_key.take(); + decryptor_state.clear_hpke_private_key(); + + let hpke_private_key = hpke_private_key.ok_or_else(|| { + status::failed_precondition( + "DecryptorState does not have an active hpke_private_key (call restore_encrypted_hpke_private_key first)", + ) + })?; + + if !recovery_request.encrypted_shares.is_empty() { + return Err(status::invalid_argument( + "RecoveryRequest must not populate legacy unindexed encrypted_shares", + )); + } + if !recovery_request.dealer_commitments.is_empty() { + self.bind_setup_commitments(recovery_request.dealer_commitments, decryptor_state)?; + } + if decryptor_state.setup_transcript_hash.is_none() { + return Err(status::failed_precondition( + "handle_recovery_request requires bound dealer_commitments / setup_transcript_hash", + )); + } + + let cfg = decryptor_state.committee_config.as_ref().ok_or_else(|| { + status::failed_precondition( + "handle_recovery_request requires committee_config in DecryptorState", + ) + })?; + + let max_dropouts = (cfg.num_decryptors - cfg.threshold) as usize; + let requested_count = recovery_request.indexed_encrypted_shares.len(); + if requested_count == 0 { + return Err(status::invalid_argument( + "RecoveryRequest must contain at least one encrypted share", + )); + } + if requested_count > max_dropouts { + return Err(status::invalid_argument( + "RecoveryRequest exceeds maximum allowed dropouts (num_decryptors - threshold)", + )); + } + + let mut indexed_decrypted_shares = BTreeMap::new(); + for (&dealer_index, ciphertext) in &recovery_request.indexed_encrypted_shares { + if dealer_index.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "RecoveryRequest dealer_index exceeds committee_config.num_decryptors", + )); + } + if dealer_index == cfg.my_index { + return Err(status::invalid_argument( + "RecoveryRequest must not request a decryptor's own dealer share", + )); + } + if ciphertext.is_empty() || ciphertext.len() > MAX_ENCRYPTED_SHARE_BYTES { + return Err(status::invalid_argument( + "RecoveryRequest encrypted share ciphertext must be in 1..=MAX_ENCRYPTED_SHARE_BYTES", + )); + } + let dealer_commitment = + decryptor_state.dealer_commitments.get(&dealer_index).ok_or_else(|| { + status::invalid_argument( + "RecoveryRequest dealer_index is missing from bound dealer_commitments", + ) + })?; + if dealer_commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "Bound dealer commitment threshold does not match committee_config.threshold", + )); + } + + let share = TinkHpkeShareCipher::decrypt_share( + &hpke_private_key, + ciphertext, + &cfg.session_context, + &cfg.roster_hash, + dealer_index, + cfg.my_index, + )?; + feldman_vss::verify_share(dealer_commitment, &share)?; + + indexed_decrypted_shares.insert(dealer_index, share); + } + + Ok(RecoveryResponse { + decrypted_shares: Vec::new(), + survivor_index: Some(cfg.my_index), + indexed_decrypted_shares, + }) } } @@ -368,6 +1041,175 @@ mod tests { Ok(()) } + #[gtest] + fn verify_key_contributions_rejects_duplicate_public_key_shares() -> googletest::Result<()> { + use messages::VerifyKeyContributionsRequest; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(2)?, CONTEXT_STRING)?); + let decryptor1 = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let decryptor2 = Decryptor::new_with_randomly_generated_seed(vahe)?; + + let mut state1 = DecryptorState::default(); + let mut state2 = DecryptorState::default(); + let contribution1 = decryptor1.create_setup_contribution(&mut state1)?; + let contribution2 = decryptor2.create_setup_contribution(&mut state2)?; + + let request = VerifyKeyContributionsRequest { + key_contributions: vec![ + contribution1.key_contribution, + contribution2.key_contribution.clone(), + contribution2.key_contribution, + ], + }; + let result = decryptor1.verify_and_aggregate_key_contributions(request); + verify_true!( + result.err().map(|e| e.code()) == Some(status::StatusErrorCode::INVALID_ARGUMENT) + )?; + Ok(()) + } + + #[gtest] + fn verify_key_contributions_with_state_enforces_own_pk_share_and_min_contributors( + ) -> googletest::Result<()> { + use messages::VerifyKeyContributionsRequest; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(3)?, CONTEXT_STRING)?); + let tee_decryptor = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let peer1 = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let peer2 = Decryptor::new_with_randomly_generated_seed(vahe)?; + + let mut tee_state = DecryptorState::default(); + let mut peer1_state = DecryptorState::default(); + let mut peer2_state = DecryptorState::default(); + + // Calling before setup fails with FailedPrecondition. + let c1 = peer1.create_setup_contribution(&mut peer1_state)?; + let c2 = peer2.create_setup_contribution(&mut peer2_state)?; + let pre_setup_res = tee_decryptor.verify_and_aggregate_key_contributions_with_state( + VerifyKeyContributionsRequest { key_contributions: vec![c1.key_contribution.clone()] }, + &tee_state, + 1, + ); + verify_true!( + pre_setup_res.err().map(|e| e.code()) + == Some(status::StatusErrorCode::FAILED_PRECONDITION) + )?; + + let c_tee = tee_decryptor.create_setup_contribution(&mut tee_state)?; + verify_true!(tee_state.pk_share.is_some())?; + + // Omitting TEE's own public_key_share (Coordinator substitutes peer1 + peer2) is rejected. + let missing_own_res = tee_decryptor.verify_and_aggregate_key_contributions_with_state( + VerifyKeyContributionsRequest { + key_contributions: vec![c1.key_contribution.clone(), c2.key_contribution.clone()], + }, + &tee_state, + 2, + ); + verify_true!( + missing_own_res.err().map(|e| e.code()) + == Some(status::StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Fewer contributions than min_contributors is rejected. + let too_few_res = tee_decryptor.verify_and_aggregate_key_contributions_with_state( + VerifyKeyContributionsRequest { + key_contributions: vec![ + c_tee.key_contribution.clone(), + c1.key_contribution.clone(), + ], + }, + &tee_state, + 3, + ); + verify_true!( + too_few_res.err().map(|e| e.code()) == Some(status::StatusErrorCode::INVALID_ARGUMENT) + )?; + + // Including TEE's own share + distinct peer shares >= min_contributors succeeds. + let pk = tee_decryptor.verify_and_aggregate_key_contributions_with_state( + VerifyKeyContributionsRequest { + key_contributions: vec![ + c1.key_contribution, + c_tee.key_contribution, + c2.key_contribution, + ], + }, + &tee_state, + 3, + )?; + let _ = pk; + Ok(()) + } + + #[gtest] + fn regenerate_state_matches_create_setup_contribution_and_preserves_prng_stream( + ) -> googletest::Result<()> { + use messages::{PartialDecryptionRequest, VerifyKeyContributionsRequest}; + use prng_traits::SecurePrng; + use protobuf::Serialize; + use shell_kahe::ShellKahe; + use single_thread_hkdf::SingleThreadHkdfPrng; + use std::cell::RefCell; + use vahe_traits::VerifiableEncrypt; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(2)?, CONTEXT_STRING)?); + let seed = SingleThreadHkdfPrng::generate_seed()?; + + let dec_setup = Decryptor { + vahe: vahe.clone(), + prng: RefCell::new(SingleThreadHkdfPrng::create(&seed)?), + }; + let mut state_setup = DecryptorState::default(); + let contrib = dec_setup.create_setup_contribution(&mut state_setup)?; + + // Seed-only TEE pattern: reconstruct a fresh Decryptor from the same seed and call + // regenerate_state() (fast non-ZK key_gen). + let dec_regen = Decryptor { + vahe: vahe.clone(), + prng: RefCell::new(SingleThreadHkdfPrng::create(&seed)?), + }; + let mut state_regen = DecryptorState::default(); + dec_regen.regenerate_state(&mut state_regen)?; + + verify_true!(state_regen.pk_share.as_ref() == state_setup.pk_share.as_ref())?; + verify_true!( + state_regen.pk_share.as_ref() == Some(&contrib.key_contribution.public_key_share) + )?; + + // Verify that verify_and_aggregate_key_contributions_with_state succeeds with state_regen. + let pk = dec_regen.verify_and_aggregate_key_contributions_with_state( + VerifyKeyContributionsRequest { key_contributions: vec![contrib.key_contribution] }, + &state_regen, + 1, + )?; + + // Verify that subsequent partial decryption draws the exact same flooding noise from prng. + let enc_seed = SingleThreadHkdfPrng::generate_seed()?; + let mut enc_prng = SingleThreadHkdfPrng::create(&enc_seed)?; + let plaintext = vec![7i64; 8]; + let (ct, _) = vahe.verifiable_encrypt(&plaintext, &pk, b"nonce123", &mut enc_prng)?; + let ct_1 = vahe.get_partial_dec_ciphertext(&ct)?; + + let pd_setup = dec_setup.handle_partial_decryption_request::( + PartialDecryptionRequest { + partial_dec_ciphertext: ct_1.clone(), + aggregation_config: None, + }, + None, + &mut state_setup, + )?; + let pd_regen = dec_regen.handle_partial_decryption_request::( + PartialDecryptionRequest { partial_dec_ciphertext: ct_1, aggregation_config: None }, + None, + &mut state_regen, + )?; + let pd_setup_bytes = pd_setup.partial_decryption.to_proto(vahe.as_ref())?.serialize()?; + let pd_regen_bytes = pd_regen.partial_decryption.to_proto(vahe.as_ref())?.serialize()?; + verify_true!(pd_setup_bytes == pd_regen_bytes)?; + Ok(()) + } + #[gtest] fn multi_decryptor_partial_decryption_works() -> googletest::Result<()> { use messages::PartialDecryptionResponse; @@ -413,4 +1255,393 @@ mod tests { Ok(()) } + + #[gtest] + fn decryptor_state_with_committee_config_and_status_roundtrip() -> googletest::Result<()> { + use messages::{CommitteeConfig, DecryptorStatus}; + use share_cipher::{ShareCipher, TinkHpkeKeysetWrappingKey, TinkHpkeShareCipher}; + use std::num::NonZeroU32; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, CONTEXT_STRING)?); + let decryptor = Decryptor::new_with_randomly_generated_seed(vahe)?; + + let cfg = CommitteeConfig::new( + NonZeroU32::new(2).unwrap(), + 3, + 2, + [0x33u8; 32], + b"sess_ctx".to_vec(), + )?; + let (hpke_sk, hpke_pk) = TinkHpkeShareCipher::generate_keypair()?; + let wrapping_key = TinkHpkeKeysetWrappingKey::generate()?; + let aad = b"decryptor_state_aad"; + + let mut state = DecryptorState::default(); + state.committee_config = Some(cfg.clone()); + state.set_encrypted_hpke_private_key(&hpke_sk, &wrapping_key, aad)?; + verify_true!(state.status == DecryptorStatus::PreSetup)?; + verify_true!(state.encrypted_hpke_private_keyset().is_some())?; + + // PreSetup roundtrip with committee_config and encrypted hpke_private_keyset. + let proto = state.to_proto(&decryptor)?; + let mut roundtrip = DecryptorState::from_proto(proto, &decryptor)?; + verify_true!(roundtrip.status == DecryptorStatus::PreSetup)?; + verify_true!(roundtrip.committee_config.as_ref() == Some(&cfg))?; + verify_true!( + roundtrip.encrypted_hpke_private_keyset() == state.encrypted_hpke_private_keyset() + )?; + // Restoring with wrong AAD fails; restoring with matching AAD recovers the HPKE keypair. + verify_true!(roundtrip + .restore_encrypted_hpke_private_key(&wrapping_key, b"wrong_aad") + .is_err())?; + let restored_sk = roundtrip.restore_encrypted_hpke_private_key(&wrapping_key, aad)?; + verify_true!(restored_sk.public_key()? == hpke_pk)?; + + // Setup transitions status to AwaitingDecryptionRequest and sets decryptor_index on + // contribution. + let contrib = decryptor.create_setup_contribution(&mut state)?; + verify_true!(state.status == DecryptorStatus::AwaitingDecryptionRequest)?; + verify_true!(contrib.decryptor_index == Some(NonZeroU32::new(2).unwrap()))?; + + let proto = state.to_proto(&decryptor)?; + let roundtrip = DecryptorState::from_proto(proto, &decryptor)?; + verify_true!(roundtrip.status == DecryptorStatus::AwaitingDecryptionRequest)?; + verify_true!(roundtrip.sk_share.is_some())?; + verify_true!(roundtrip.committee_config.as_ref() == Some(&cfg))?; + + // Rejects PreSetup when sk_share is present. + let mut invalid_proto = state.to_proto(&decryptor)?; + invalid_proto.set_status(messages_rust_proto::DecryptorStatus::PreSetup); + verify_true!(DecryptorState::from_proto(invalid_proto, &decryptor).is_err())?; + + // Rejects AwaitingDecryptionRequest when sk_share is missing. + let mut missing_sk_proto = DecryptorState::default().to_proto(&decryptor)?; + missing_sk_proto + .set_status(messages_rust_proto::DecryptorStatus::AwaitingDecryptionRequest); + verify_true!(DecryptorState::from_proto(missing_sk_proto, &decryptor).is_err())?; + + // Legacy backward compatibility: Unspecified status (proto3 default 0) on a + // single-decryptor state (`committee_config == None && hpke_private_keyset == None`) + // infers PreSetup when sk_share is absent and AwaitingDecryptionRequest when sk_share is + // present. + let mut legacy_empty_proto = DecryptorState::default().to_proto(&decryptor)?; + legacy_empty_proto.set_status(messages_rust_proto::DecryptorStatus::Unspecified); + let legacy_empty = DecryptorState::from_proto(legacy_empty_proto, &decryptor)?; + verify_true!(legacy_empty.status == DecryptorStatus::PreSetup)?; + + let mut single_dec_state = DecryptorState::default(); + decryptor.create_setup_contribution(&mut single_dec_state)?; + let mut legacy_sk_proto = single_dec_state.to_proto(&decryptor)?; + legacy_sk_proto.set_status(messages_rust_proto::DecryptorStatus::Unspecified); + let legacy_sk = DecryptorState::from_proto(legacy_sk_proto, &decryptor)?; + verify_true!(legacy_sk.status == DecryptorStatus::AwaitingDecryptionRequest)?; + verify_true!(legacy_sk.sk_share.is_some())?; + + // Recoverable committee state (`committee_config` or `hpke_private_keyset` present) + // strictly rejects Unspecified status (`B7`). + let mut committee_unspec_proto = state.to_proto(&decryptor)?; + committee_unspec_proto.set_status(messages_rust_proto::DecryptorStatus::Unspecified); + verify_true!(DecryptorState::from_proto(committee_unspec_proto, &decryptor).is_err())?; + + Ok(()) + } + + #[gtest] + fn recoverable_setup_and_one_shot_recovery_end_to_end() -> googletest::Result<()> { + use feldman_vss::derive_keygen_seed; + use messages::{ + compute_roster_hash, CommitteeConfig, DecryptorStatus, PartialDecryptionRequest, + PartialDecryptionResponse, RecoveryRequest, + }; + use prng_traits::SecurePrng; + use share_cipher::{ShareCipher, TinkHpkeShareCipher}; + use shell_kahe::ShellKahe; + use single_thread_hkdf::SingleThreadHkdfPrng; + use std::collections::BTreeMap; + use std::num::NonZeroU32; + use vahe_traits::{VerifiableEncrypt, VerifiableKeyGen}; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, CONTEXT_STRING)?); + let num_decryptors = 4u32; + let threshold = 3u32; + let session_context = b"session_recoverable_e2e".to_vec(); + + // Generate HPKE keypairs for all 4 committee members. + let mut hpke_sks = BTreeMap::new(); + let mut roster_pks = BTreeMap::new(); + let mut raw_roster = BTreeMap::new(); + for i in 1..=num_decryptors { + let idx = NonZeroU32::new(i).unwrap(); + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + raw_roster.insert(idx, pk.as_bytes().to_vec()); + hpke_sks.insert(idx, sk); + roster_pks.insert(idx, pk); + } + let roster_hash = compute_roster_hash(num_decryptors, threshold, &raw_roster)?; + + // Each decryptor runs `create_recoverable_setup_contribution`. + let mut decryptors = BTreeMap::new(); + let mut states = BTreeMap::new(); + let mut contributions = BTreeMap::new(); + let mut dealer_commitments = BTreeMap::new(); + for i in 1..=num_decryptors { + let idx = NonZeroU32::new(i).unwrap(); + let dec = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut state = DecryptorState::default(); + let cfg = CommitteeConfig::new( + idx, + num_decryptors, + threshold, + roster_hash, + session_context.clone(), + )?; + let contrib = dec.create_recoverable_setup_contribution( + cfg, + &roster_pks, + &hpke_sks[&idx], + &mut state, + )?; + verify_true!(state.status == DecryptorStatus::AwaitingDecryptionRequest)?; + verify_true!(contrib.decryptor_index == Some(idx))?; + verify_true!(contrib.indexed_encrypted_shares.len() == (num_decryptors - 1) as usize)?; + dealer_commitments.insert(idx, contrib.feldman_commitment.clone().unwrap()); + decryptors.insert(idx, dec); + states.insert(idx, state); + contributions.insert(idx, contrib); + } + + // Suppose decryptor 4 drops out after setup; survivors {1, 2, 3} perform partial decryption + // and then decrypt their shares for dealer 4. + let idx1 = NonZeroU32::new(1).unwrap(); + let idx2 = NonZeroU32::new(2).unwrap(); + let idx3 = NonZeroU32::new(3).unwrap(); + let idx4 = NonZeroU32::new(4).unwrap(); + + let pk_shares: Vec<_> = + contributions.values().map(|c| &c.key_contribution.public_key_share).collect(); + let joint_pk = vahe.aggregate_public_key_shares(pk_shares.into_iter())?; + + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let plaintext = vec![7i64; 8]; + let (ct, _) = vahe.verifiable_encrypt(&plaintext, &joint_pk, b"nonce_e2e", &mut prng)?; + let pd_ct = vahe.get_partial_dec_ciphertext(&ct)?; + + // Survivors {1, 2, 3} each handle partial decryption -> transition to + // AwaitingRecoveryRequest; a second partial decryption call on Survivor 1 is strictly + // rejected (`B7`). + let pd_req = PartialDecryptionRequest { + partial_dec_ciphertext: pd_ct.clone(), + aggregation_config: None, + }; + for &survivor_idx in &[idx1, idx2, idx3] { + let _pd_resp: PartialDecryptionResponse = + decryptors[&survivor_idx].handle_partial_decryption_request( + pd_req.clone(), + None, + states.get_mut(&survivor_idx).unwrap(), + )?; + verify_true!(states[&survivor_idx].status == DecryptorStatus::AwaitingRecoveryRequest)?; + } + let second_pd: Result, _> = decryptors + [&idx1] + .handle_partial_decryption_request(pd_req, None, states.get_mut(&idx1).unwrap()); + verify_true!(second_pd.is_err())?; + + // Survivors {1, 2, 3} each handle a one-shot RecoveryRequest for dropped dealer 4. + let mut recovered_shares_for_dealer4 = Vec::new(); + for &survivor_idx in &[idx1, idx2, idx3] { + let enc_share = contributions[&idx4].indexed_encrypted_shares[&survivor_idx].clone(); + let mut indexed_encrypted_shares = BTreeMap::new(); + indexed_encrypted_shares.insert(idx4, enc_share); + let req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares, + dealer_commitments: dealer_commitments.clone(), + }; + let resp = decryptors[&survivor_idx] + .handle_recovery_request(req, states.get_mut(&survivor_idx).unwrap())?; + verify_true!(states[&survivor_idx].status == DecryptorStatus::Finished)?; + verify_true!(states[&survivor_idx].sk_share.is_none())?; + verify_true!(states[&survivor_idx].hpke_private_key.is_none())?; + verify_true!(states[&survivor_idx].encrypted_hpke_private_keyset().is_none())?; + verify_true!(resp.survivor_index == Some(survivor_idx))?; + recovered_shares_for_dealer4.push(resp.indexed_decrypted_shares[&idx4].clone()); + } + + // Reconstruct dealer 4's `vss_secret` from the 3 verified shares, re-derive `keygen_seed`, + // and verify that `verifiable_key_gen` Deterministically reproduces dealer 4's exact + // `public_key_share`! + let reconstructed_secret = + feldman_vss::reconstruct(&dealer_commitments[&idx4], &recovered_shares_for_dealer4)?; + let regenerated_seed = + derive_keygen_seed(&reconstructed_secret, &session_context, &roster_hash, idx4)?; + let mut regen_prng = SingleThreadHkdfPrng::create(®enerated_seed)?; + let (_regen_sk4, regen_pk4, _regen_proof4) = vahe.verifiable_key_gen(&mut regen_prng)?; + let expected_pk4_proto = + contributions[&idx4].key_contribution.public_key_share.to_proto(vahe.as_ref())?; + let regen_pk4_proto = regen_pk4.to_proto(vahe.as_ref())?; + verify_true!( + protobuf::Serialize::serialize(®en_pk4_proto).unwrap() + == protobuf::Serialize::serialize(&expected_pk4_proto).unwrap() + )?; + + Ok(()) + } + + #[gtest] + fn recovery_rejects_excess_dropouts_forged_shares_equivocation_and_burns_state( + ) -> googletest::Result<()> { + use feldman_vss::{SecretScalar, Share}; + use messages::{compute_roster_hash, CommitteeConfig, DecryptorStatus, RecoveryRequest}; + use share_cipher::{ShareCipher, TinkHpkeShareCipher}; + use std::collections::BTreeMap; + use std::num::NonZeroU32; + + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, CONTEXT_STRING)?); + let num_decryptors = 3u32; + let threshold = 2u32; // max_dropouts = n - t = 1 + let session_context = b"session_adversarial_recovery".to_vec(); + + let mut hpke_sks = BTreeMap::new(); + let mut roster_pks = BTreeMap::new(); + let mut raw_roster = BTreeMap::new(); + for i in 1..=num_decryptors { + let idx = NonZeroU32::new(i).unwrap(); + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + raw_roster.insert(idx, pk.as_bytes().to_vec()); + hpke_sks.insert(idx, sk); + roster_pks.insert(idx, pk); + } + let roster_hash = compute_roster_hash(num_decryptors, threshold, &raw_roster)?; + + let idx1 = NonZeroU32::new(1).unwrap(); + let idx2 = NonZeroU32::new(2).unwrap(); + let idx3 = NonZeroU32::new(3).unwrap(); + + let mut decryptors = BTreeMap::new(); + let mut states = BTreeMap::new(); + let mut contributions = BTreeMap::new(); + let mut dealer_commitments = BTreeMap::new(); + for &idx in &[idx1, idx2, idx3] { + let dec = Decryptor::new_with_randomly_generated_seed(vahe.clone())?; + let mut state = DecryptorState::default(); + let cfg = CommitteeConfig::new( + idx, + num_decryptors, + threshold, + roster_hash, + session_context.clone(), + )?; + let contrib = dec.create_recoverable_setup_contribution( + cfg, + &roster_pks, + &hpke_sks[&idx], + &mut state, + )?; + dealer_commitments.insert(idx, contrib.feldman_commitment.clone().unwrap()); + decryptors.insert(idx, dec); + states.insert(idx, state); + contributions.insert(idx, contrib); + } + + // Calling `handle_recovery_request` before `handle_partial_decryption_request` + // (while status is still `AwaitingDecryptionRequest`) is rejected with FailedPrecondition + // without burning the state. + let mut premature_shares = BTreeMap::new(); + premature_shares.insert(idx2, contributions[&idx2].indexed_encrypted_shares[&idx1].clone()); + let premature_req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares: premature_shares, + dealer_commitments: dealer_commitments.clone(), + }; + verify_true!(decryptors[&idx1] + .handle_recovery_request(premature_req, states.get_mut(&idx1).unwrap()) + .is_err())?; + verify_true!(states[&idx1].status == DecryptorStatus::AwaitingDecryptionRequest)?; + + // Transition all 3 decryptors to `AwaitingRecoveryRequest` to test recovery checks. + for &idx in &[idx1, idx2, idx3] { + states.get_mut(&idx).unwrap().status = DecryptorStatus::AwaitingRecoveryRequest; + } + + // 1. Requesting > n - t shares (2 shares when n - t = 1) is rejected AND immediately + // burns state1 to `DecryptorStatus::Finished` so a subsequent valid 1-share request + // is also rejected (`C1` / `A2` one-shot guarantee). + let mut two_shares = BTreeMap::new(); + two_shares.insert(idx2, contributions[&idx2].indexed_encrypted_shares[&idx1].clone()); + two_shares.insert(idx3, contributions[&idx3].indexed_encrypted_shares[&idx1].clone()); + let over_threshold_req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares: two_shares, + dealer_commitments: dealer_commitments.clone(), + }; + verify_true!(decryptors[&idx1] + .handle_recovery_request(over_threshold_req, states.get_mut(&idx1).unwrap()) + .is_err())?; + verify_true!(states[&idx1].status == DecryptorStatus::Finished)?; + verify_true!(states[&idx1].hpke_private_key.is_none())?; + + // Retry with a valid 1-share request on burned `state1` must fail with FailedPrecondition. + let mut valid_one_share = BTreeMap::new(); + valid_one_share.insert(idx2, contributions[&idx2].indexed_encrypted_shares[&idx1].clone()); + let retry_req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares: valid_one_share, + dealer_commitments: dealer_commitments.clone(), + }; + verify_true!(decryptors[&idx1] + .handle_recovery_request(retry_req, states.get_mut(&idx1).unwrap()) + .is_err())?; + + // 2. Coordinator equivocation on survivor 2's own commitment at `my_index = 2`: + // replacing `dealer_commitments[2]` with `dealer_commitments[3]` is rejected! + let mut equivocated_commitments = dealer_commitments.clone(); + equivocated_commitments.insert(idx2, dealer_commitments[&idx3].clone()); + verify_true!(decryptors[&idx2] + .bind_setup_commitments(equivocated_commitments, states.get_mut(&idx2).unwrap()) + .is_err())?; + + // 3. Forged share attack (`#75`): an untrusted Coordinator encrypts an arbitrary non-zero + // scalar under survivor 2's public key with valid HPKE `context_info` for `(dealer=3, + // recipient=2)`. HPKE decryption succeeds, but `feldman_vss::verify_share` against + // `dealer_commitments[3]` catches the forged share and rejects! + let forged_share = + Share { index: idx2, value: SecretScalar::from_canonical_bytes(&[7u8; 32])? }; + let forged_ct = TinkHpkeShareCipher::encrypt_share( + &roster_pks[&idx2], + &forged_share, + &session_context, + &roster_hash, + idx3, + )?; + let mut forged_map = BTreeMap::new(); + forged_map.insert(idx3, forged_ct); + let forged_req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares: forged_map, + dealer_commitments: dealer_commitments.clone(), + }; + verify_true!(decryptors[&idx2] + .handle_recovery_request(forged_req, states.get_mut(&idx2).unwrap()) + .is_err())?; + verify_true!(states[&idx2].status == DecryptorStatus::Finished)?; + + // 4. Oversized ciphertext (> MAX_ENCRYPTED_SHARE_BYTES) and self-dealer request + // (`dealer_index == my_index = 3`) are rejected and burn state. + let mut oversized_map = BTreeMap::new(); + oversized_map.insert(idx1, vec![0x22; messages::MAX_ENCRYPTED_SHARE_BYTES + 1]); + let oversized_req = RecoveryRequest { + encrypted_shares: Vec::new(), + indexed_encrypted_shares: oversized_map, + dealer_commitments: dealer_commitments.clone(), + }; + verify_true!(decryptors[&idx3] + .handle_recovery_request(oversized_req, states.get_mut(&idx3).unwrap()) + .is_err())?; + verify_true!(states[&idx3].status == DecryptorStatus::Finished)?; + verify_true!(states[&idx3].sk_share.is_none())?; + + Ok(()) + } } diff --git a/willow/protocol/messages.rs b/willow/protocol/messages.rs index 08c911e..de94481 100644 --- a/willow/protocol/messages.rs +++ b/willow/protocol/messages.rs @@ -14,12 +14,14 @@ use aggregation_config::AggregationConfig; use ahe_traits::AheBase; +use feldman_vss::{FeldmanCommitment, Share, MAX_SHARES, ROSTER_HASH_BYTE_LEN}; use kahe_traits::{HasKahe, KaheBase}; use messages_rust_proto::{ CiphertextContribution as CiphertextContributionProto, ClientMessage as ClientMessageProto, - CoordinatorState as CoordinatorStateProto, CoordinatorStatus as CoordinatorStatusProto, - DPSetupContribution as DPSetupContributionProto, - DecryptionRequestContribution as DecryptionRequestContributionProto, + CommitteeConfigProto, CoordinatorState as CoordinatorStateProto, + CoordinatorStatus as CoordinatorStatusProto, DPSetupContribution as DPSetupContributionProto, + DealerSetupInfoProto, DecryptionRequestContribution as DecryptionRequestContributionProto, + DecryptorStatus as DecryptorStatusProto, EncryptedRandomnessShares as EncryptedRandomnessSharesProto, FinalizedPartialDecryption as FinalizedPartialDecryptionProto, KeyContribution as KeyContributionProto, @@ -38,13 +40,287 @@ use shell_ciphertexts_rust_proto::{ ShellAhePublicKeyShare, ShellAheRecoverCiphertext, ShellKaheCiphertext, }; use status::StatusError; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Debug; +use std::num::NonZeroU32; use vahe_traits::{HasVahe, VaheBase}; pub type DecryptorPublicKeyShare = ::PublicKeyShare; pub type DecryptorPublicKey = ::PublicKey; +/// Domain separator for computing the 32-byte pre-setup committee `roster_hash`. +pub const COMMITTEE_ROSTER_HASH_INFO: &[u8] = b"Committee.roster"; + +/// Domain separator for computing the 32-byte Phase-1 setup commitment transcript hash. +pub const SETUP_TRANSCRIPT_HASH_INFO: &[u8] = b"Committee.commitments"; + +/// Maximum byte length of a single HPKE-encrypted 32-byte Feldman VSS share +/// (Tink HPKE X25519-HKDF-SHA256-AES128-GCM ciphertexts are ~92–97 bytes). +pub const MAX_ENCRYPTED_SHARE_BYTES: usize = 1024; + +/// Configuration for a non-reputable decryptor committee member participating in +/// Feldman VSS + HPKE dropout recovery. +/// +/// # Threat Model & Threshold Bounds +/// - **Active Hybrid Threat Model (with >= 1 honest Reputable Decryptor TEE)**: Enforces `2 <= +/// num_decryptors <= MAX_SHARES` (`100`), `1 <= threshold <= num_decryptors - 1` (since a dropped +/// dealer is offline and cannot return its own self-share, at most `num_decryptors - 1` peer +/// shares exist), and `1 <= my_index <= num_decryptors`. `Coordinator::handle_setup_submissions` +/// does not verify ZK `KeyGenProof`s itself—it returns `VerifyKeyContributionsRequest` for a +/// Reputable Decryptor TEE to verify—and therefore requires at least one `reputable_contribution` +/// (`!reputable_contributions.is_empty()`) in recoverable committee mode (matching +/// `WillowProtocolImpl::ValidateRuntimeDependencies`). +/// - **Pure No-TEE Threat Model (not instantiated by `Coordinator`)**: Without a Reputable +/// Decryptor TEE privacy anchor or a signed survivor-set agreement round among committee members, +/// resisting Coordinator split-request equivocation would additionally require `2 * threshold > +/// num_decryptors + max_corrupt` (i.e., `2 * max_recoverable_dropouts < num_decryptors - +/// max_corrupt`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitteeConfig { + pub my_index: NonZeroU32, + pub num_decryptors: u32, + pub threshold: u32, + pub roster_hash: [u8; ROSTER_HASH_BYTE_LEN], + pub session_context: Vec, +} + +impl CommitteeConfig { + /// Constructs and validates a `CommitteeConfig`. + pub fn new( + my_index: NonZeroU32, + num_decryptors: u32, + threshold: u32, + roster_hash: [u8; ROSTER_HASH_BYTE_LEN], + session_context: Vec, + ) -> Result { + let config = Self { my_index, num_decryptors, threshold, roster_hash, session_context }; + config.validate()?; + Ok(config) + } + + /// Validates all committee parameter invariants: + /// - `2 <= num_decryptors <= MAX_SHARES` (`100`) + /// - `1 <= threshold <= num_decryptors - 1` + /// - `1 <= my_index <= num_decryptors` + /// - `!session_context.is_empty()` + pub fn validate(&self) -> Result<(), StatusError> { + if self.num_decryptors < 2 || self.num_decryptors > MAX_SHARES { + return Err(status::invalid_argument( + "CommitteeConfig: num_decryptors must be in 2..=MAX_SHARES", + )); + } + if self.threshold < 1 || self.threshold >= self.num_decryptors { + return Err(status::invalid_argument( + "CommitteeConfig: threshold must be in 1..=(num_decryptors - 1)", + )); + } + if self.my_index.get() > self.num_decryptors { + return Err(status::invalid_argument( + "CommitteeConfig: my_index exceeds num_decryptors", + )); + } + if self.session_context.is_empty() { + return Err(status::invalid_argument( + "CommitteeConfig: session_context must not be empty", + )); + } + Ok(()) + } + + /// Maximum number of dropped committee members (`n - t`) whose shares an honest + /// survivor is permitted to decrypt in a single one-shot `RecoveryRequest`. + pub fn max_recoverable_dropouts(&self) -> u32 { + self.num_decryptors - self.threshold + } +} + +impl ToProto for CommitteeConfig { + type Proto = CommitteeConfigProto; + + fn to_proto(&self, _ctx: ()) -> Result { + self.validate()?; + Ok(proto!(CommitteeConfigProto { + my_index: self.my_index.get(), + num_decryptors: self.num_decryptors, + threshold: self.threshold, + roster_hash: self.roster_hash.as_slice(), + session_context: self.session_context.as_slice(), + })) + } +} + +impl FromProto for CommitteeConfig { + type Proto = CommitteeConfigProto; + + fn from_proto( + proto: impl AsView, + _ctx: (), + ) -> Result { + let proto = proto.as_view(); + let my_index = NonZeroU32::new(proto.my_index()).ok_or_else(|| { + status::invalid_argument("CommitteeConfigProto: my_index must be non-zero") + })?; + let roster_hash_bytes = proto.roster_hash(); + let roster_hash: [u8; ROSTER_HASH_BYTE_LEN] = + roster_hash_bytes.try_into().map_err(|_| { + status::invalid_argument("CommitteeConfigProto: roster_hash must be 32 bytes") + })?; + CommitteeConfig::new( + my_index, + proto.num_decryptors(), + proto.threshold(), + roster_hash, + proto.session_context().to_vec(), + ) + } +} + +/// Computes the canonical 32-byte `roster_hash` over the pre-setup committee roster: +/// `(threshold, num_decryptors, sorted [(decryptor_index, u32_le(pk_len), hpke_public_key)])`. +/// +/// Because `roster_hash` is an input to `derive_keygen_seed` in Phase 1 and +/// `build_share_hpke_context_info` in Phase 2, it is defined strictly over the pre-setup +/// committee roster before any `FeldmanCommitment`s are broadcast. +/// +/// # Security Contract (Roster Attestation Trust Boundary — Audit A3 item 4) +/// +/// `compute_roster_hash` binds the pre-setup mapping `1..=num_decryptors -> hpke_public_key`, +/// preventing any post-setup equivocation on the roster. However, the protocol library +/// itself does not authenticate how `roster_public_keys` was assembled. Callers (e.g., +/// committee devices / executors) **MUST** obtain `roster_public_keys` from an authenticated +/// or attested roster source (or verify hardware/identity attestations on peer HPKE public +/// keys) so an untrusted Coordinator cannot populate the roster with Sybil HPKE public keys +/// it controls before `compute_roster_hash` is computed. +pub fn compute_roster_hash( + num_decryptors: u32, + threshold: u32, + roster_public_keys: &BTreeMap>, +) -> Result<[u8; ROSTER_HASH_BYTE_LEN], StatusError> { + if num_decryptors < 2 || num_decryptors > MAX_SHARES { + return Err(status::invalid_argument( + "compute_roster_hash: num_decryptors must be in 2..=MAX_SHARES", + )); + } + if threshold < 1 || threshold >= num_decryptors { + return Err(status::invalid_argument( + "compute_roster_hash: threshold must be in 1..=(num_decryptors - 1)", + )); + } + if roster_public_keys.len() != num_decryptors as usize { + return Err(status::invalid_argument( + "compute_roster_hash: roster_public_keys count must equal num_decryptors", + )); + } + + let mut ikm = Vec::new(); + ikm.extend_from_slice(&threshold.to_le_bytes()); + ikm.extend_from_slice(&num_decryptors.to_le_bytes()); + for (expected_idx, (&idx, pk_bytes)) in (1..=num_decryptors).zip(roster_public_keys.iter()) { + if idx.get() != expected_idx { + return Err(status::invalid_argument( + "compute_roster_hash: roster indices must be 1..=num_decryptors", + )); + } + if pk_bytes.is_empty() { + return Err(status::invalid_argument( + "compute_roster_hash: empty public key in roster", + )); + } + let pk_len = u32::try_from(pk_bytes.len()).map_err(|_| { + status::invalid_argument("compute_roster_hash: public key length exceeds u32::MAX") + })?; + ikm.extend_from_slice(&idx.get().to_le_bytes()); + ikm.extend_from_slice(&pk_len.to_le_bytes()); + ikm.extend_from_slice(pk_bytes); + } + + let seed = single_thread_hkdf::compute_hkdf( + &ikm, + b"", + COMMITTEE_ROSTER_HASH_INFO, + ROSTER_HASH_BYTE_LEN as u64, + )?; + let bytes = seed.as_bytes(); + bytes + .try_into() + .map_err(|_| status::internal("compute_roster_hash: unexpected HKDF output length")) +} + +/// Computes the canonical 32-byte `setup_transcript_hash` binding `roster_hash`, +/// `session_context`, `(threshold, num_decryptors)`, and all broadcast +/// `(dealer_index, FeldmanCommitment)` pairs in ascending `dealer_index` order +/// (`1..=num_decryptors`). +pub fn compute_setup_transcript_hash( + roster_hash: &[u8; ROSTER_HASH_BYTE_LEN], + session_context: &[u8], + threshold: u32, + num_decryptors: u32, + commitments: &BTreeMap, +) -> Result<[u8; ROSTER_HASH_BYTE_LEN], StatusError> { + if session_context.is_empty() { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: session_context must not be empty", + )); + } + if num_decryptors < 2 || num_decryptors > MAX_SHARES { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: num_decryptors must be in 2..=MAX_SHARES", + )); + } + if threshold < 1 || threshold >= num_decryptors { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: threshold must be in 1..=(num_decryptors - 1)", + )); + } + if commitments.len() != num_decryptors as usize { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: commitments count must equal num_decryptors", + )); + } + + let mut ikm = Vec::new(); + ikm.extend_from_slice(roster_hash); + let ctx_len = u32::try_from(session_context.len()).map_err(|_| { + status::invalid_argument( + "compute_setup_transcript_hash: session_context length exceeds u32::MAX", + ) + })?; + ikm.extend_from_slice(&ctx_len.to_le_bytes()); + ikm.extend_from_slice(session_context); + ikm.extend_from_slice(&threshold.to_le_bytes()); + ikm.extend_from_slice(&num_decryptors.to_le_bytes()); + for (expected_idx, (&dealer_idx, commitment)) in (1..=num_decryptors).zip(commitments.iter()) { + if dealer_idx.get() != expected_idx { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: dealer indices must be 1..=num_decryptors", + )); + } + if commitment.threshold() != threshold as usize { + return Err(status::invalid_argument( + "compute_setup_transcript_hash: commitment threshold does not match committee threshold", + )); + } + ikm.extend_from_slice(&dealer_idx.get().to_le_bytes()); + let deg_count = commitment.threshold() as u32; + ikm.extend_from_slice(°_count.to_le_bytes()); + for pt in commitment.commitments() { + ikm.extend_from_slice(pt.as_bytes()); + } + } + + let seed = single_thread_hkdf::compute_hkdf( + &ikm, + roster_hash, + SETUP_TRANSCRIPT_HASH_INFO, + ROSTER_HASH_BYTE_LEN as u64, + )?; + let bytes = seed.as_bytes(); + bytes.try_into().map_err(|_| { + status::internal("compute_setup_transcript_hash: unexpected HKDF output length") + }) +} + /// Message sent by a generic KAHE/AHE Willow client to the server. #[derive(Debug)] pub struct ClientMessage { @@ -187,9 +463,12 @@ impl Debug for PartialDecryptionRequest { pub struct PartialDecryptionResponse { pub partial_decryption: Vahe::PartialDecryption, // This contribution just contains encrypted DP noise. The server will be forced to include - // this contribution in the result because the randomness of the AHE encryption was - // included in the partial decryption request. + // this contribution in the result because the randomness of the AHE encryption was included in + // the partial decryption request. pub dp_ciphertext_contribution: Option>, + /// 1-based committee index of the responding decryptor (`1..=MAX_SHARES`) in + /// recoverable multi-decryptor mode (`None` in single/reputable/unindexed mode). + pub decryptor_index: Option, } impl<'a, C, Kahe, Vahe> ToProto<(&'a C, Option<&'a Kahe>)> for PartialDecryptionResponse @@ -222,6 +501,14 @@ where } )); } + if let Some(idx) = self.decryptor_index { + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "PartialDecryptionResponse: decryptor_index exceeds MAX_SHARES", + )); + } + proto.set_decryptor_index(idx.get()); + } Ok(proto) } } @@ -247,12 +534,24 @@ where } else { None }; + let decryptor_index = if proto.decryptor_index() == 0 { + None + } else { + let idx = NonZeroU32::new(proto.decryptor_index()).unwrap(); + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "PartialDecryptionResponseProto: decryptor_index exceeds MAX_SHARES", + )); + } + Some(idx) + }; Ok(PartialDecryptionResponse { partial_decryption: Vahe::PartialDecryption::from_proto( proto.partial_decryption(), context.vahe(), )?, dp_ciphertext_contribution, + decryptor_index, }) } } @@ -383,6 +682,15 @@ pub struct KeyContribution { pub proof: Option, } +impl Clone for KeyContribution +where + Vahe::KeyGenProof: Clone, +{ + fn clone(&self) -> Self { + Self { public_key_share: self.public_key_share.clone(), proof: self.proof.clone() } + } +} + /// The initial contribution to the decryption key from a decryptor. pub struct SetupContribution { pub key_contribution: KeyContribution, @@ -391,12 +699,22 @@ pub struct SetupContribution { /// Shares of the randomness (PRNG state) used for key generation, encrypted for other /// decryptors to enable recovery. Only used by non-reputable decryptors. pub encrypted_randomness_shares: Option>, + /// 1-based committee index of this decryptor (`1..=MAX_SHARES`), present in + /// recoverable multi-decryptor committee mode. + pub decryptor_index: Option, + /// Public Feldman VSS polynomial commitment (`C_0, ..., C_{t-1}`) to the + /// dealer's secret scalar and polynomial coefficients. + pub feldman_commitment: Option, + /// Encrypted randomness shares keyed by 1-based `recipient_index` (`1..=MAX_SHARES`). + pub indexed_encrypted_shares: BTreeMap>, } -/// Placeholder for an encrypted share of the PRNG state. -#[derive(Debug, Clone)] +/// An encrypted share of the randomness seed for secret sharing. +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SecretSharingContribution { pub encrypted_share: Vec, + /// Optional 1-based recipient decryptor index (`1..=MAX_SHARES`). + pub recipient_index: Option, } /// Public key independent half of ciphertext for noise, only present for adding DP. @@ -409,17 +727,29 @@ pub struct DPSetupContribution { } /// A request to a surviving decryptor to decrypt shares of dropped decryptors. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RecoveryRequest { - /// The encrypted shares that this decryptor is being asked to decrypt. + /// Legacy unindexed encrypted shares. pub encrypted_shares: Vec, + /// Encrypted shares keyed by 1-based dropped `dealer_index` (`1..=MAX_SHARES`). + pub indexed_encrypted_shares: BTreeMap>, + /// Broadcast Feldman VSS polynomial commitments keyed by 1-based `dealer_index` + /// (`1..=MAX_SHARES`), used by the surviving decryptor to verify the setup + /// commitment transcript and each decrypted share (`feldman_vss::verify_share`). + pub dealer_commitments: BTreeMap, } /// A response containing the decrypted shares of dropped decryptors. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RecoveryResponse { - /// The decrypted shares. + /// Legacy unindexed decrypted shares. pub decrypted_shares: Vec>, + /// 1-based committee index of the surviving decryptor (`1..=MAX_SHARES`) + /// sending this response. + pub survivor_index: Option, + /// Decrypted Feldman VSS shares keyed by 1-based dropped `dealer_index` (`1..=MAX_SHARES`). + /// Each `Share` has `share.index == survivor_index`. + pub indexed_decrypted_shares: BTreeMap, } /// A request from the coordinator to a reputable decryptor to verify setup contributions @@ -439,7 +769,18 @@ impl ToProto for SecretSharingContribution { type Proto = SecretSharingContributionProto; fn to_proto(&self, _ctx: ()) -> Result { - Ok(proto!(SecretSharingContributionProto { encrypted_share: self.encrypted_share.clone() })) + let mut proto = proto!(SecretSharingContributionProto { + encrypted_share: self.encrypted_share.clone(), + }); + if let Some(idx) = self.recipient_index { + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SecretSharingContribution: recipient_index exceeds MAX_SHARES", + )); + } + proto.set_recipient_index(idx.get()); + } + Ok(proto) } } @@ -451,7 +792,21 @@ impl FromProto for SecretSharingContribution { _ctx: (), ) -> Result { let proto = proto.as_view(); - Ok(SecretSharingContribution { encrypted_share: proto.encrypted_share().to_vec() }) + let recipient_index = if proto.recipient_index() == 0 { + None + } else { + let idx = NonZeroU32::new(proto.recipient_index()).unwrap(); + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SecretSharingContributionProto: recipient_index exceeds MAX_SHARES", + )); + } + Some(idx) + }; + Ok(SecretSharingContribution { + encrypted_share: proto.encrypted_share().to_vec(), + recipient_index, + }) } } @@ -576,6 +931,44 @@ where proto.encrypted_randomness_shares_mut().push(share.to_proto(())?); } } + if let Some(dealer_idx) = self.decryptor_index { + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SetupContribution: decryptor_index exceeds MAX_SHARES", + )); + } + proto.set_decryptor_index(dealer_idx.get()); + } + if let Some(commitment) = &self.feldman_commitment { + proto.set_feldman_commitment(commitment.to_proto(())?); + } + if !self.indexed_encrypted_shares.is_empty() { + let dealer_idx = self.decryptor_index.ok_or_else(|| { + status::invalid_argument( + "SetupContribution: decryptor_index is required when indexed_encrypted_shares is non-empty", + ) + })?; + for (&recipient_idx, ciphertext) in &self.indexed_encrypted_shares { + if recipient_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SetupContribution: recipient_index exceeds MAX_SHARES", + )); + } + if recipient_idx == dealer_idx { + return Err(status::invalid_argument( + "SetupContribution: dealer must not include self-share in indexed_encrypted_shares", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "SetupContribution: encrypted share must not be empty", + )); + } + proto + .indexed_encrypted_shares_mut() + .insert(recipient_idx.get(), ciphertext.as_slice()); + } + } Ok(proto) } } @@ -614,10 +1007,58 @@ where Some(shares) } }; + let decryptor_index = if proto.decryptor_index() == 0 { + None + } else { + let idx = NonZeroU32::new(proto.decryptor_index()).unwrap(); + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SetupContributionProto: decryptor_index exceeds MAX_SHARES", + )); + } + Some(idx) + }; + let feldman_commitment = if proto.has_feldman_commitment() { + Some(FeldmanCommitment::from_proto(proto.feldman_commitment(), ())?) + } else { + None + }; + let mut indexed_encrypted_shares = BTreeMap::new(); + for (raw_recipient_idx, ciphertext) in proto.indexed_encrypted_shares().iter() { + let dealer_idx = decryptor_index.ok_or_else(|| { + status::invalid_argument( + "SetupContributionProto: decryptor_index is required when indexed_encrypted_shares is non-empty", + ) + })?; + let recipient_idx = NonZeroU32::new(raw_recipient_idx).ok_or_else(|| { + status::invalid_argument( + "SetupContributionProto: recipient_index in indexed_encrypted_shares must be non-zero", + ) + })?; + if recipient_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "SetupContributionProto: recipient_index exceeds MAX_SHARES", + )); + } + if recipient_idx == dealer_idx { + return Err(status::invalid_argument( + "SetupContributionProto: dealer must not include self-share in indexed_encrypted_shares", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "SetupContributionProto: encrypted share must not be empty", + )); + } + indexed_encrypted_shares.insert(recipient_idx, ciphertext.to_vec()); + } Ok(SetupContribution { key_contribution: KeyContribution::from_proto(proto.key_contribution(), context)?, dp_setup, encrypted_randomness_shares, + decryptor_index, + feldman_commitment, + indexed_encrypted_shares, }) } } @@ -630,6 +1071,27 @@ impl ToProto for RecoveryRequest { for share in &self.encrypted_shares { proto.encrypted_shares_mut().push(share.to_proto(())?); } + for (&dealer_idx, ciphertext) in &self.indexed_encrypted_shares { + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryRequest: dealer_index exceeds MAX_SHARES", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "RecoveryRequest: encrypted_share must not be empty", + )); + } + proto.indexed_encrypted_shares_mut().insert(dealer_idx.get(), ciphertext.as_slice()); + } + for (&dealer_idx, commitment) in &self.dealer_commitments { + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryRequest: dealer_commitments dealer_index exceeds MAX_SHARES", + )); + } + proto.dealer_commitments_mut().insert(dealer_idx.get(), commitment.to_proto(())?); + } Ok(proto) } } @@ -647,7 +1109,43 @@ impl FromProto for RecoveryRequest { .iter() .map(|s| SecretSharingContribution::from_proto(s, ())) .collect(); - Ok(RecoveryRequest { encrypted_shares: encrypted_shares? }) + let mut indexed_encrypted_shares = BTreeMap::new(); + for (raw_dealer_idx, ciphertext) in proto.indexed_encrypted_shares().iter() { + let dealer_idx = NonZeroU32::new(raw_dealer_idx).ok_or_else(|| { + status::invalid_argument("RecoveryRequestProto: dealer_index must be non-zero") + })?; + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryRequestProto: dealer_index exceeds MAX_SHARES", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "RecoveryRequestProto: encrypted_share must not be empty", + )); + } + indexed_encrypted_shares.insert(dealer_idx, ciphertext.to_vec()); + } + let mut dealer_commitments = BTreeMap::new(); + for (raw_dealer_idx, commitment_proto) in proto.dealer_commitments().iter() { + let dealer_idx = NonZeroU32::new(raw_dealer_idx).ok_or_else(|| { + status::invalid_argument( + "RecoveryRequestProto: dealer_commitments dealer_index must be non-zero", + ) + })?; + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryRequestProto: dealer_commitments dealer_index exceeds MAX_SHARES", + )); + } + let commitment = FeldmanCommitment::from_proto(commitment_proto, ())?; + dealer_commitments.insert(dealer_idx, commitment); + } + Ok(RecoveryRequest { + encrypted_shares: encrypted_shares?, + indexed_encrypted_shares, + dealer_commitments, + }) } } @@ -655,9 +1153,43 @@ impl ToProto for RecoveryResponse { type Proto = RecoveryResponseProto; fn to_proto(&self, _ctx: ()) -> Result { - Ok(proto!(RecoveryResponseProto { + let mut proto = proto!(RecoveryResponseProto { decrypted_shares: self.decrypted_shares.iter().map(|s| s.as_slice()), - })) + }); + if let Some(survivor_idx) = self.survivor_index { + if survivor_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryResponse: survivor_index exceeds MAX_SHARES", + )); + } + proto.set_survivor_index(survivor_idx.get()); + } + if !self.indexed_decrypted_shares.is_empty() { + let survivor_idx = self.survivor_index.ok_or_else(|| { + status::invalid_argument( + "RecoveryResponse: survivor_index is required when indexed_decrypted_shares is non-empty", + ) + })?; + for (&dealer_idx, share) in &self.indexed_decrypted_shares { + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryResponse: dealer_index exceeds MAX_SHARES", + )); + } + if dealer_idx == survivor_idx { + return Err(status::invalid_argument( + "RecoveryResponse: dealer_index must not equal survivor_index", + )); + } + if share.index != survivor_idx { + return Err(status::invalid_argument( + "RecoveryResponse: share.index must match survivor_index", + )); + } + proto.indexed_decrypted_shares_mut().insert(dealer_idx.get(), share.to_proto(())?); + } + } + Ok(proto) } } @@ -671,7 +1203,46 @@ impl FromProto for RecoveryResponse { let proto = proto.as_view(); let decrypted_shares: Vec> = proto.decrypted_shares().iter().map(|s| s.to_vec()).collect(); - Ok(RecoveryResponse { decrypted_shares }) + let survivor_index = if proto.survivor_index() == 0 { + None + } else { + let idx = NonZeroU32::new(proto.survivor_index()).unwrap(); + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryResponseProto: survivor_index exceeds MAX_SHARES", + )); + } + Some(idx) + }; + let mut indexed_decrypted_shares = BTreeMap::new(); + for (raw_dealer_idx, share_proto) in proto.indexed_decrypted_shares().iter() { + let survivor_idx = survivor_index.ok_or_else(|| { + status::invalid_argument( + "RecoveryResponseProto: survivor_index is required when indexed_decrypted_shares is non-empty", + ) + })?; + let dealer_idx = NonZeroU32::new(raw_dealer_idx).ok_or_else(|| { + status::invalid_argument("RecoveryResponseProto: dealer_index must be non-zero") + })?; + if dealer_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "RecoveryResponseProto: dealer_index exceeds MAX_SHARES", + )); + } + if dealer_idx == survivor_idx { + return Err(status::invalid_argument( + "RecoveryResponseProto: dealer_index must not equal survivor_index", + )); + } + let share = Share::from_proto(share_proto, ())?; + if share.index != survivor_idx { + return Err(status::invalid_argument( + "RecoveryResponseProto: share.index must match survivor_index", + )); + } + indexed_decrypted_shares.insert(dealer_idx, share); + } + Ok(RecoveryResponse { decrypted_shares, survivor_index, indexed_decrypted_shares }) } } @@ -754,17 +1325,57 @@ where } } -/// Tracks a multi-decryptor's progress through the protocol. +/// Tracks a decryptor's progress through the protocol for strict one-shot enforcement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DecryptorStatus { #[default] PreSetup, - AwaitingKeyVerificationRequest, AwaitingDecryptionRequest, AwaitingRecoveryRequest, Finished, } +impl ToProto for DecryptorStatus { + type Proto = DecryptorStatusProto; + + fn to_proto(&self, _ctx: ()) -> Result { + Ok(match self { + DecryptorStatus::PreSetup => DecryptorStatusProto::PreSetup, + DecryptorStatus::AwaitingDecryptionRequest => { + DecryptorStatusProto::AwaitingDecryptionRequest + } + DecryptorStatus::AwaitingRecoveryRequest => { + DecryptorStatusProto::AwaitingRecoveryRequest + } + DecryptorStatus::Finished => DecryptorStatusProto::Finished, + }) + } +} + +impl FromProto for DecryptorStatus { + type Proto = DecryptorStatusProto; + + fn from_proto( + proto: impl AsView, + _ctx: (), + ) -> Result { + match proto.as_view() { + DecryptorStatusProto::PreSetup => Ok(DecryptorStatus::PreSetup), + DecryptorStatusProto::AwaitingDecryptionRequest => { + Ok(DecryptorStatus::AwaitingDecryptionRequest) + } + DecryptorStatusProto::AwaitingRecoveryRequest => { + Ok(DecryptorStatus::AwaitingRecoveryRequest) + } + DecryptorStatusProto::Finished => Ok(DecryptorStatus::Finished), + DecryptorStatusProto::Unspecified => { + Err(status::invalid_argument("DecryptorStatus is unspecified")) + } + _ => Err(status::invalid_argument("Unknown DecryptorStatus")), + } + } +} + /// State stored by a multi-decryptor between protocol rounds. pub struct MultiDecryptorState { /// The current status of the decryptor in the protocol. @@ -781,6 +1392,132 @@ impl Default for MultiDecryptorState { } } +/// Per-dealer recoverable setup metadata stored by the coordinator for +/// Feldman VSS dropout recovery. +pub struct DealerSetupInfo { + /// 1-based committee index of the dealer (`1..=MAX_SHARES`). + pub dealer_index: NonZeroU32, + /// Broadcast Feldman VSS polynomial commitment (`C_0, ..., C_{t-1}`). + pub feldman_commitment: FeldmanCommitment, + /// Public key share submitted by this dealer during setup (used to verify + /// regenerated `pk_share == expected_pk_share` during recovery). + pub public_key_share: DecryptorPublicKeyShare, + /// Encrypted randomness shares dealt by this dealer, keyed by 1-based + /// `recipient_index` (`1..=MAX_SHARES`). + pub encrypted_shares: BTreeMap>, +} + +impl Clone for DealerSetupInfo { + fn clone(&self) -> Self { + Self { + dealer_index: self.dealer_index, + feldman_commitment: self.feldman_commitment.clone(), + public_key_share: self.public_key_share.clone(), + encrypted_shares: self.encrypted_shares.clone(), + } + } +} + +impl<'a, C, Vahe> ToProto<&'a C> for DealerSetupInfo +where + C: HasVahe, + Vahe: VaheBase + 'a, + Vahe::PublicKeyShare: ToProto<&'a Vahe, Proto = ShellAhePublicKeyShare>, +{ + type Proto = DealerSetupInfoProto; + + fn to_proto(&self, context: &'a C) -> Result { + if self.dealer_index.get() > MAX_SHARES { + return Err(status::invalid_argument( + "DealerSetupInfo: dealer_index exceeds MAX_SHARES", + )); + } + let mut proto = proto!(DealerSetupInfoProto { + dealer_index: self.dealer_index.get(), + feldman_commitment: self.feldman_commitment.to_proto(())?, + public_key_share: self.public_key_share.to_proto(context.vahe())?, + }); + for (&recipient_idx, ciphertext) in &self.encrypted_shares { + if recipient_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "DealerSetupInfo: recipient_index exceeds MAX_SHARES", + )); + } + if recipient_idx == self.dealer_index { + return Err(status::invalid_argument( + "DealerSetupInfo: dealer must not include self-share", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "DealerSetupInfo: encrypted share must not be empty", + )); + } + proto.encrypted_shares_mut().insert(recipient_idx.get(), ciphertext.as_slice()); + } + Ok(proto) + } +} + +impl<'a, C, Vahe> FromProto<&'a C> for DealerSetupInfo +where + C: HasVahe, + Vahe: VaheBase + 'a, + Vahe::PublicKeyShare: FromProto<&'a Vahe, Proto = ShellAhePublicKeyShare>, +{ + type Proto = DealerSetupInfoProto; + + fn from_proto( + proto: impl AsView, + context: &'a C, + ) -> Result { + let proto = proto.as_view(); + let dealer_index = NonZeroU32::new(proto.dealer_index()).ok_or_else(|| { + status::invalid_argument("DealerSetupInfoProto: dealer_index must be non-zero") + })?; + if dealer_index.get() > MAX_SHARES { + return Err(status::invalid_argument( + "DealerSetupInfoProto: dealer_index exceeds MAX_SHARES", + )); + } + if !proto.has_feldman_commitment() { + return Err(status::invalid_argument( + "DealerSetupInfoProto: missing feldman_commitment", + )); + } + let feldman_commitment = FeldmanCommitment::from_proto(proto.feldman_commitment(), ())?; + if !proto.has_public_key_share() { + return Err(status::invalid_argument("DealerSetupInfoProto: missing public_key_share")); + } + let public_key_share = + Vahe::PublicKeyShare::from_proto(proto.public_key_share(), context.vahe())?; + + let mut encrypted_shares = BTreeMap::new(); + for (raw_recipient_idx, ciphertext) in proto.encrypted_shares().iter() { + let recipient_idx = NonZeroU32::new(raw_recipient_idx).ok_or_else(|| { + status::invalid_argument("DealerSetupInfoProto: recipient_index must be non-zero") + })?; + if recipient_idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "DealerSetupInfoProto: recipient_index exceeds MAX_SHARES", + )); + } + if recipient_idx == dealer_index { + return Err(status::invalid_argument( + "DealerSetupInfoProto: dealer must not include self-share", + )); + } + if ciphertext.is_empty() { + return Err(status::invalid_argument( + "DealerSetupInfoProto: encrypted share must not be empty", + )); + } + encrypted_shares.insert(recipient_idx, ciphertext.to_vec()); + } + Ok(DealerSetupInfo { dealer_index, feldman_commitment, public_key_share, encrypted_shares }) + } +} + /// Tracks the coordinator's progress through the multi-decryptor protocol. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CoordinatorStatus { @@ -807,11 +1544,33 @@ pub struct CoordinatorState { /// applicable. pub dp_noise_component_sum: Option, /// The public key shares and proofs from all contributions, for sending back to decryptors for - /// signing requests. Only applicable in multiple reputable decryptor case and until used. + /// signing requests. In legacy multi-reputable-decryptor mode this holds all setup + /// contributions until consumed; in recoverable committee mode this records the reputable + /// decryptors' setup contributions (`public_key_share` with `proof: None`, as the ZK proofs + /// were already forwarded in `VerifyKeyContributionsRequest` during setup) so + /// `aggregate_partial_decryptions` enforces `unindexed_count == expected_reputable_count`. + /// Note: if multiple reputable decryptors are supported alongside a recoverable committee in + /// the future, reputable `PartialDecryptionResponse`s should carry an authenticated reputable + /// decryptor identifier to prevent duplicate submissions from a single reputable decryptor. pub setup_contributions: Option>>, /// Sum of partial decryptions received from decryptors, set by /// `aggregate_partial_decryptions`. pub partial_decryption_sum: Option, + /// 1-based committee indices of non-reputable decryptors that have responded with a + /// `PartialDecryptionResponse`. + pub responded_decryptor_indices: BTreeSet, + /// Saved partial decryption request ciphertext (`ct_1`) from `prepare_decryption_request`, + /// needed to compute simulated partial decryptions for dropped decryptors during recovery. + pub partial_dec_ciphertext: Option, + /// Per-dealer Feldman VSS commitment, setup `PublicKeyShare`, and encrypted shares + /// keyed by 1-based `dealer_index` (`1..=MAX_SHARES`). + pub dealer_setup_info: BTreeMap>, + /// Optional committee configuration (`num_decryptors`, `threshold`, `roster_hash`, + /// `session_context`) when running with a recoverable committee. + pub committee_config: Option, + /// Optional 32-byte setup transcript hash binding all broadcast + /// `(dealer_index, FeldmanCommitment)` pairs. + pub setup_transcript_hash: Option<[u8; ROSTER_HASH_BYTE_LEN]>, } impl Default for CoordinatorState { @@ -822,6 +1581,11 @@ impl Default for CoordinatorState { dp_noise_component_sum: None, setup_contributions: None, partial_decryption_sum: None, + responded_decryptor_indices: BTreeSet::new(), + partial_dec_ciphertext: None, + dealer_setup_info: BTreeMap::new(), + committee_config: None, + setup_transcript_hash: None, } } } @@ -905,6 +1669,50 @@ where if let Some(pd_sum) = &self.partial_decryption_sum { proto.set_partial_decryption_sum(pd_sum.to_proto(context.vahe())?); } + if let Some(cfg) = &self.committee_config { + proto.set_committee_config(cfg.to_proto(())?); + } + for &idx in &self.responded_decryptor_indices { + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "CoordinatorState: responded_decryptor_index exceeds MAX_SHARES", + )); + } + if let Some(cfg) = &self.committee_config { + if idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "CoordinatorState: responded_decryptor_index exceeds committee num_decryptors", + )); + } + } + proto.responded_decryptor_indices_mut().push(idx.get()); + } + if let Some(pd_ct) = &self.partial_dec_ciphertext { + proto.set_partial_dec_ciphertext(pd_ct.to_proto(context.vahe())?); + } + for (&dealer_idx, info) in &self.dealer_setup_info { + if dealer_idx != info.dealer_index { + return Err(status::invalid_argument( + "CoordinatorState: dealer_setup_info map key does not match info.dealer_index", + )); + } + if let Some(cfg) = &self.committee_config { + if dealer_idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "CoordinatorState: dealer_index exceeds committee num_decryptors", + )); + } + if info.feldman_commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "CoordinatorState: dealer FeldmanCommitment threshold does not match committee threshold", + )); + } + } + proto.dealer_setup_info_mut().insert(dealer_idx.get(), info.to_proto(context)?); + } + if let Some(transcript_hash) = &self.setup_transcript_hash { + proto.set_setup_transcript_hash(transcript_hash.as_slice()); + } Ok(proto) } } @@ -968,11 +1776,96 @@ where } else { None }; + let committee_config = if proto.has_committee_config() { + Some(CommitteeConfig::from_proto(proto.committee_config(), ())?) + } else { + None + }; + let mut responded_decryptor_indices = BTreeSet::new(); + for raw_idx in proto.responded_decryptor_indices().iter() { + let idx = NonZeroU32::new(raw_idx).ok_or_else(|| { + status::invalid_argument( + "CoordinatorStateProto: responded_decryptor_index must be non-zero", + ) + })?; + if idx.get() > MAX_SHARES { + return Err(status::invalid_argument( + "CoordinatorStateProto: responded_decryptor_index exceeds MAX_SHARES", + )); + } + if let Some(cfg) = &committee_config { + if idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "CoordinatorStateProto: responded_decryptor_index exceeds committee num_decryptors", + )); + } + } + if !responded_decryptor_indices.insert(idx) { + return Err(status::invalid_argument( + "CoordinatorStateProto: duplicate responded_decryptor_index", + )); + } + } + let partial_dec_ciphertext = if proto.has_partial_dec_ciphertext() { + Some(Vahe::PartialDecCiphertext::from_proto( + proto.partial_dec_ciphertext(), + context.vahe(), + )?) + } else { + None + }; + let mut dealer_setup_info = BTreeMap::new(); + for (raw_dealer_idx, info_proto) in proto.dealer_setup_info().iter() { + let dealer_idx = NonZeroU32::new(raw_dealer_idx).ok_or_else(|| { + status::invalid_argument("CoordinatorStateProto: dealer_index must be non-zero") + })?; + let info = DealerSetupInfo::from_proto(info_proto, context)?; + if info.dealer_index != dealer_idx { + return Err(status::invalid_argument( + "CoordinatorStateProto: dealer_setup_info key does not match info.dealer_index", + )); + } + if let Some(cfg) = &committee_config { + if dealer_idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "CoordinatorStateProto: dealer_index exceeds committee num_decryptors", + )); + } + if info.feldman_commitment.threshold() != cfg.threshold as usize { + return Err(status::invalid_argument( + "CoordinatorStateProto: dealer FeldmanCommitment threshold does not match committee threshold", + )); + } + for &recipient_idx in info.encrypted_shares.keys() { + if recipient_idx.get() > cfg.num_decryptors { + return Err(status::invalid_argument( + "CoordinatorStateProto: recipient_index exceeds committee num_decryptors", + )); + } + } + } + dealer_setup_info.insert(dealer_idx, info); + } + let setup_transcript_hash = if proto.setup_transcript_hash().is_empty() { + None + } else { + let hash: [u8; ROSTER_HASH_BYTE_LEN] = + proto.setup_transcript_hash().try_into().map_err(|_| { + status::invalid_argument( + "CoordinatorStateProto: setup_transcript_hash must be 32 bytes", + ) + })?; + Some(hash) + }; if status == CoordinatorStatus::PreSetup && (!encrypted_randomness_shares.is_empty() || dp_noise_component_sum.is_some() || setup_contributions.is_some() - || partial_decryption_sum.is_some()) + || partial_decryption_sum.is_some() + || !responded_decryptor_indices.is_empty() + || partial_dec_ciphertext.is_some() + || !dealer_setup_info.is_empty() + || setup_transcript_hash.is_some()) { return Err(status::invalid_argument( "CoordinatorState in PreSetup status must not have populated protocol fields", @@ -989,6 +1882,407 @@ where dp_noise_component_sum, setup_contributions, partial_decryption_sum, + responded_decryptor_indices, + partial_dec_ciphertext, + dealer_setup_info, + committee_config, + setup_transcript_hash, }) } } + +#[cfg(test)] +mod tests { + use super::*; + use ahe_traits::{AheBase, PartialDec}; + use feldman_vss::{split, SecretScalar}; + use googletest::prelude::*; + use prng_traits::SecurePrng; + use protobuf::prelude::*; + use shell_kahe::ShellKahe; + use shell_parameters::create_shell_ahe_config; + use shell_vahe::ShellVahe; + use single_thread_hkdf::SingleThreadHkdfPrng; + use std::rc::Rc; + use vahe_traits::{VerifiableEncrypt, VerifiableKeyGen}; + + const TEST_CONTEXT: &[u8] = b"messages_test_context"; + + fn sample_split( + threshold: u32, + num_shares: u32, + ) -> Result<(FeldmanCommitment, Vec), StatusError> { + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let secret = SecretScalar::random(&mut prng)?; + split(&secret, threshold, num_shares, &mut prng) + } + + struct TestVaheContext { + vahe: Rc, + } + + impl HasVahe for TestVaheContext { + type Vahe = ShellVahe; + fn vahe(&self) -> &ShellVahe { + &self.vahe + } + } + + impl HasKahe for TestVaheContext { + type Kahe = ShellKahe; + fn kahe(&self) -> &ShellKahe { + panic!("KAHE not used in messages_test") + } + } + + fn nz(v: u32) -> NonZeroU32 { + NonZeroU32::new(v).unwrap() + } + + #[gtest] + fn committee_config_validation_and_proto_roundtrip() -> googletest::Result<()> { + let cfg = CommitteeConfig::new( + nz(2), + 5, + 3, + [0x42u8; ROSTER_HASH_BYTE_LEN], + b"session-123".to_vec(), + )?; + let proto = cfg.to_proto(())?; + let roundtrip = CommitteeConfig::from_proto(proto.as_view(), ())?; + verify_that!(roundtrip, eq(&cfg))?; + + // Boundary: n = 2, t = 1. + verify_true!(CommitteeConfig::new(nz(1), 2, 1, [0x11; 32], b"ctx".to_vec()).is_ok())?; + // Boundary: n = MAX_SHARES, t = MAX_SHARES - 1. + verify_true!(CommitteeConfig::new( + nz(MAX_SHARES), + MAX_SHARES, + MAX_SHARES - 1, + [0x11; 32], + b"ctx".to_vec(), + ) + .is_ok())?; + + // Rejects n < 2, n > MAX_SHARES, t == 0, t >= n, my_index > n, empty session_context. + verify_true!(CommitteeConfig::new(nz(1), 1, 1, [0; 32], b"ctx".to_vec()).is_err())?; + verify_true!( + CommitteeConfig::new(nz(1), MAX_SHARES + 1, 2, [0; 32], b"ctx".to_vec()).is_err() + )?; + verify_true!(CommitteeConfig::new(nz(1), 3, 0, [0; 32], b"ctx".to_vec()).is_err())?; + verify_true!(CommitteeConfig::new(nz(1), 3, 3, [0; 32], b"ctx".to_vec()).is_err())?; + verify_true!(CommitteeConfig::new(nz(4), 3, 2, [0; 32], b"ctx".to_vec()).is_err())?; + verify_true!(CommitteeConfig::new(nz(1), 3, 2, [0; 32], vec![]).is_err())?; + + // Proto rejections: my_index == 0, bad roster_hash length. + let mut bad_proto = cfg.to_proto(())?; + bad_proto.set_my_index(0); + verify_true!(CommitteeConfig::from_proto(bad_proto.as_view(), ()).is_err())?; + + let mut bad_hash_proto = cfg.to_proto(())?; + bad_hash_proto.set_roster_hash(&[1u8; 31][..]); + verify_true!(CommitteeConfig::from_proto(bad_hash_proto.as_view(), ()).is_err())?; + + Ok(()) + } + + #[gtest] + fn compute_roster_hash_deterministic_and_rejects_malformed_roster() -> googletest::Result<()> { + let mut r1 = BTreeMap::new(); + r1.insert(nz(3), b"pk3".to_vec()); + r1.insert(nz(1), b"pk1".to_vec()); + r1.insert(nz(2), b"pk2".to_vec()); + + let mut r2 = BTreeMap::new(); + r2.insert(nz(1), b"pk1".to_vec()); + r2.insert(nz(2), b"pk2".to_vec()); + r2.insert(nz(3), b"pk3".to_vec()); + + let h1 = compute_roster_hash(3, 2, &r1)?; + let h2 = compute_roster_hash(3, 2, &r2)?; + verify_that!(h1, eq(h2))?; + + // Changing threshold or a public key changes the hash. + let h_diff_t = compute_roster_hash(3, 1, &r1)?; + verify_that!(h1, not(eq(h_diff_t)))?; + + let mut r_tampered = r1.clone(); + r_tampered.insert(nz(2), b"pk2_tampered".to_vec()); + let h_diff_pk = compute_roster_hash(3, 2, &r_tampered)?; + verify_that!(h1, not(eq(h_diff_pk)))?; + + // Missing contiguous index (gap at 2) rejected. + let mut r_gap = BTreeMap::new(); + r_gap.insert(nz(1), b"pk1".to_vec()); + r_gap.insert(nz(3), b"pk3".to_vec()); + r_gap.insert(nz(4), b"pk4".to_vec()); + verify_true!(compute_roster_hash(3, 2, &r_gap).is_err())?; + + // Empty public key rejected. + let mut r_empty_pk = r1; + r_empty_pk.insert(nz(2), vec![]); + verify_true!(compute_roster_hash(3, 2, &r_empty_pk).is_err())?; + + Ok(()) + } + + #[gtest] + fn compute_setup_transcript_hash_deterministic_and_validates_commitments( + ) -> googletest::Result<()> { + let (c1, _) = sample_split(2, 3)?; + let (c2, _) = sample_split(2, 3)?; + let (c3, _) = sample_split(2, 3)?; + let roster_hash = [0x55u8; ROSTER_HASH_BYTE_LEN]; + + let mut map1 = BTreeMap::new(); + map1.insert(nz(3), c3.clone()); + map1.insert(nz(1), c1.clone()); + map1.insert(nz(2), c2.clone()); + + let mut map2 = BTreeMap::new(); + map2.insert(nz(1), c1.clone()); + map2.insert(nz(2), c2); + map2.insert(nz(3), c3.clone()); + + let t1 = compute_setup_transcript_hash(&roster_hash, b"sess-ctx", 2, 3, &map1)?; + let t2 = compute_setup_transcript_hash(&roster_hash, b"sess-ctx", 2, 3, &map2)?; + verify_that!(t1, eq(t2))?; + + // Commitment with wrong threshold (t = 1 instead of 2) is rejected. + let (c_wrong_t, _) = sample_split(1, 3)?; + let mut map_wrong_t = BTreeMap::new(); + map_wrong_t.insert(nz(1), c1); + map_wrong_t.insert(nz(2), c_wrong_t); + map_wrong_t.insert(nz(3), c3); + verify_true!( + compute_setup_transcript_hash(&roster_hash, b"sess-ctx", 2, 3, &map_wrong_t,).is_err() + )?; + + Ok(()) + } + + #[gtest] + fn decryptor_status_roundtrip_and_rejects_unspecified() -> googletest::Result<()> { + for status in [ + DecryptorStatus::PreSetup, + DecryptorStatus::AwaitingDecryptionRequest, + DecryptorStatus::AwaitingRecoveryRequest, + DecryptorStatus::Finished, + ] { + let proto = status.to_proto(())?; + let roundtrip = DecryptorStatus::from_proto(proto, ())?; + verify_that!(roundtrip, eq(status))?; + } + verify_true!(DecryptorStatus::from_proto(DecryptorStatusProto::Unspecified, ()).is_err())?; + Ok(()) + } + + #[gtest] + fn setup_contribution_and_pd_response_indexed_fields_roundtrip() -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, TEST_CONTEXT)?); + let ctx = TestVaheContext { vahe: vahe.clone() }; + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let (sk_share, pk_share, proof) = vahe.verifiable_key_gen(&mut prng)?; + let (commitment, _) = sample_split(2, 3)?; + + let mut indexed_encrypted_shares = BTreeMap::new(); + indexed_encrypted_shares.insert(nz(2), b"hpke_ct_for_2".to_vec()); + indexed_encrypted_shares.insert(nz(3), b"hpke_ct_for_3".to_vec()); + + let contrib = SetupContribution:: { + key_contribution: KeyContribution { + public_key_share: pk_share.clone(), + proof: Some(proof), + }, + dp_setup: None, + encrypted_randomness_shares: Some(vec![SecretSharingContribution { + encrypted_share: b"legacy_ct".to_vec(), + recipient_index: Some(nz(2)), + }]), + decryptor_index: Some(nz(1)), + feldman_commitment: Some(commitment), + indexed_encrypted_shares, + }; + let proto = contrib.to_proto(&ctx)?; + let roundtrip = SetupContribution::::from_proto(proto.as_view(), &ctx)?; + verify_that!(roundtrip.decryptor_index, eq(Some(nz(1))))?; + verify_that!(roundtrip.feldman_commitment, eq(&contrib.feldman_commitment))?; + verify_that!(roundtrip.indexed_encrypted_shares, eq(&contrib.indexed_encrypted_shares))?; + verify_that!( + roundtrip.encrypted_randomness_shares.as_ref().unwrap()[0].recipient_index, + eq(Some(nz(2))) + )?; + + // Rejects self-share (recipient_index == decryptor_index). + let mut bad_self_share = contrib; + bad_self_share.indexed_encrypted_shares.insert(nz(1), b"self_ct".to_vec()); + verify_true!(bad_self_share.to_proto(&ctx).is_err())?; + + // PartialDecryptionResponse with decryptor_index roundtrips. + let pk = vahe.aggregate_public_key_shares(std::iter::once(&pk_share))?; + let (ct, _) = + vahe.verifiable_encrypt(&vec![7i64; 8], &pk, b"0123456789ABCDEF", &mut prng)?; + let pd_ct = vahe.get_partial_dec_ciphertext(&ct)?; + let pd = vahe.partial_decrypt(&pd_ct, &sk_share, &mut prng)?; + let pd_resp = PartialDecryptionResponse:: { + partial_decryption: pd, + dp_ciphertext_contribution: None, + decryptor_index: Some(nz(2)), + }; + let pd_proto = pd_resp.to_proto((&ctx, None::<&ShellKahe>))?; + let pd_roundtrip = PartialDecryptionResponse::::from_proto( + pd_proto.as_view(), + &ctx, + )?; + verify_that!(pd_roundtrip.decryptor_index, eq(Some(nz(2))))?; + + Ok(()) + } + + #[gtest] + fn recovery_request_and_response_indexed_fields_and_spoofing_rejection( + ) -> googletest::Result<()> { + let (c_d1, shares_d1) = sample_split(2, 3)?; + let (c_d3, shares_d3) = sample_split(2, 3)?; + let mut req_shares = BTreeMap::new(); + req_shares.insert(nz(1), b"ct_from_dealer_1".to_vec()); + req_shares.insert(nz(3), b"ct_from_dealer_3".to_vec()); + let mut req_commitments = BTreeMap::new(); + req_commitments.insert(nz(1), c_d1); + req_commitments.insert(nz(3), c_d3); + let req = RecoveryRequest { + encrypted_shares: vec![], + indexed_encrypted_shares: req_shares.clone(), + dealer_commitments: req_commitments, + }; + let req_proto = req.to_proto(())?; + let req_roundtrip = RecoveryRequest::from_proto(req_proto.as_view(), ())?; + verify_that!(req_roundtrip, eq(&req))?; + + // RecoveryResponse with valid shares for survivor 2 from dealers 1 and 3. + let share_from_d1_for_2 = shares_d1.into_iter().find(|s| s.index == nz(2)).unwrap(); + let share_from_d3_for_2 = shares_d3.into_iter().find(|s| s.index == nz(2)).unwrap(); + + let mut dec_shares = BTreeMap::new(); + dec_shares.insert(nz(1), share_from_d1_for_2); + dec_shares.insert(nz(3), share_from_d3_for_2); + + let resp = RecoveryResponse { + decrypted_shares: vec![], + survivor_index: Some(nz(2)), + indexed_decrypted_shares: dec_shares, + }; + let resp_proto = resp.to_proto(())?; + let resp_roundtrip = RecoveryResponse::from_proto(resp_proto.as_view(), ())?; + verify_that!(resp_roundtrip, eq(&resp))?; + + // Rejects share index spoofing (survivor 2 submitting a share with share.index == 3). + let (_, shares_spoof) = sample_split(2, 3)?; + let share_for_3 = shares_spoof.into_iter().find(|s| s.index == nz(3)).unwrap(); + let mut spoofed_map = BTreeMap::new(); + spoofed_map.insert(nz(1), share_for_3.clone()); + let spoofed_resp = RecoveryResponse { + decrypted_shares: vec![], + survivor_index: Some(nz(2)), + indexed_decrypted_shares: spoofed_map, + }; + verify_true!(spoofed_resp.to_proto(()).is_err())?; + + // Also check proto-level spoofing rejection on FromProto. + let mut raw_spoof_proto = resp.to_proto(())?; + raw_spoof_proto.indexed_decrypted_shares_mut().insert(1u32, share_for_3.to_proto(())?); + let err = RecoveryResponse::from_proto(raw_spoof_proto.as_view(), ()) + .expect_err("expected share.index mismatch error"); + verify_that!(err.message(), contains_substring("share.index must match survivor_index"))?; + + // Rejects dealer_index == survivor_index. + let mut raw_self_dealer_proto = resp.to_proto(())?; + let (_, shares_d2) = sample_split(2, 3)?; + let share_for_2 = shares_d2.into_iter().find(|s| s.index == nz(2)).unwrap(); + raw_self_dealer_proto + .indexed_decrypted_shares_mut() + .insert(2u32, share_for_2.to_proto(())?); + verify_true!(RecoveryResponse::from_proto(raw_self_dealer_proto.as_view(), ()).is_err())?; + + Ok(()) + } + + #[gtest] + fn coordinator_state_committee_and_dealer_setup_info_roundtrip() -> googletest::Result<()> { + let vahe = Rc::new(ShellVahe::new(create_shell_ahe_config(1)?, TEST_CONTEXT)?); + let ctx = TestVaheContext { vahe: vahe.clone() }; + let seed = SingleThreadHkdfPrng::generate_seed()?; + let mut prng = SingleThreadHkdfPrng::create(&seed)?; + let (_sk, pk_share, _proof) = vahe.verifiable_key_gen(&mut prng)?; + let pk = vahe.aggregate_public_key_shares(std::iter::once(&pk_share))?; + let (ct, _) = + vahe.verifiable_encrypt(&vec![1i64; 8], &pk, b"0123456789ABCDEF", &mut prng)?; + let pd_ct = vahe.get_partial_dec_ciphertext(&ct)?; + + let (commitment, _) = sample_split(2, 3)?; + let cfg = CommitteeConfig::new(nz(1), 3, 2, [0xAA; 32], b"sess-1".to_vec())?; + + let mut dealer_enc_shares = BTreeMap::new(); + dealer_enc_shares.insert(nz(2), b"ct_1_to_2".to_vec()); + dealer_enc_shares.insert(nz(3), b"ct_1_to_3".to_vec()); + + let dealer_info = DealerSetupInfo:: { + dealer_index: nz(1), + feldman_commitment: commitment, + public_key_share: pk_share, + encrypted_shares: dealer_enc_shares, + }; + let mut dealer_setup_info = BTreeMap::new(); + dealer_setup_info.insert(nz(1), dealer_info); + + let mut responded = BTreeSet::new(); + responded.insert(nz(2)); + responded.insert(nz(3)); + + let state = CoordinatorState:: { + status: CoordinatorStatus::AwaitingRecovery, + responded_decryptor_indices: responded.clone(), + partial_dec_ciphertext: Some(pd_ct), + dealer_setup_info, + committee_config: Some(cfg.clone()), + setup_transcript_hash: Some([0xBB; 32]), + ..CoordinatorState::default() + }; + + let proto = state.to_proto(&ctx)?; + let roundtrip = CoordinatorState::::from_proto(proto.as_view(), &ctx)?; + verify_that!(roundtrip.status, eq(CoordinatorStatus::AwaitingRecovery))?; + verify_that!(roundtrip.responded_decryptor_indices, eq(&responded))?; + verify_true!(roundtrip.partial_dec_ciphertext.is_some())?; + verify_that!(roundtrip.committee_config.as_ref(), eq(Some(&cfg)))?; + verify_that!(roundtrip.setup_transcript_hash, eq(Some([0xBB; 32])))?; + verify_that!(roundtrip.dealer_setup_info.len(), eq(1))?; + let rt_info = &roundtrip.dealer_setup_info[&nz(1)]; + let orig_info = &state.dealer_setup_info[&nz(1)]; + verify_that!(rt_info.dealer_index, eq(orig_info.dealer_index))?; + verify_that!(rt_info.feldman_commitment, eq(&orig_info.feldman_commitment))?; + verify_that!(rt_info.encrypted_shares, eq(&orig_info.encrypted_shares))?; + verify_that!( + rt_info.public_key_share.to_proto(ctx.vahe())?.serialize()?, + eq(&orig_info.public_key_share.to_proto(ctx.vahe())?.serialize()?) + )?; + + // Rejects duplicate responded_decryptor_indices on FromProto. + let mut dup_proto = state.to_proto(&ctx)?; + dup_proto.responded_decryptor_indices_mut().push(2); + verify_true!(CoordinatorState::::from_proto(dup_proto.as_view(), &ctx).is_err())?; + + // Rejects responded_decryptor_index > committee_config.num_decryptors. + let mut out_of_range_proto = state.to_proto(&ctx)?; + out_of_range_proto.responded_decryptor_indices_mut().push(4); + verify_true!(CoordinatorState::::from_proto( + out_of_range_proto.as_view(), + &ctx + ) + .is_err())?; + + Ok(()) + } +} diff --git a/willow/protocol/single_decryptor.rs b/willow/protocol/single_decryptor.rs index 74ba6b4..a3c4e5b 100644 --- a/willow/protocol/single_decryptor.rs +++ b/willow/protocol/single_decryptor.rs @@ -14,7 +14,9 @@ use ahe_traits::{AheKeygen, PartialDec}; use decryptor::{Decryptor, DecryptorState}; -use messages::{DecryptorPublicKey, PartialDecryptionRequest, PartialDecryptionResponse}; +use messages::{ + DecryptorPublicKey, DecryptorStatus, PartialDecryptionRequest, PartialDecryptionResponse, +}; use shell_kahe::ShellKahe; use status::StatusError; use std::rc::Rc; @@ -59,6 +61,8 @@ where let (sk_share, pk_share, _) = self.decryptor.vahe().key_gen(&mut self.decryptor.prng.borrow_mut())?; decryptor_state.sk_share = Some(sk_share); + decryptor_state.pk_share = Some(pk_share.clone()); + decryptor_state.status = DecryptorStatus::AwaitingDecryptionRequest; self.decryptor.vahe().aggregate_public_key_shares(std::iter::once(&pk_share)) } @@ -68,11 +72,18 @@ where partial_decryption_request: PartialDecryptionRequest, decryptor_state: &mut DecryptorState, ) -> Result, StatusError> { - self.decryptor.handle_partial_decryption_request( + let response = self.decryptor.handle_partial_decryption_request( partial_decryption_request, None::<&ShellKahe>, decryptor_state, - ) + )?; + // In single-decryptor mode (`committee_config.is_none()`), keep status in + // `AwaitingDecryptionRequest` so single-decryptor callers/benchmarks can evaluate + // partial decryptions across multiple verifier slices. + if decryptor_state.committee_config.is_none() { + decryptor_state.status = DecryptorStatus::AwaitingDecryptionRequest; + } + Ok(response) } } diff --git a/willow/testing_utils/BUILD b/willow/testing_utils/BUILD index fb31e9e..d50ee3c 100644 --- a/willow/testing_utils/BUILD +++ b/willow/testing_utils/BUILD @@ -115,6 +115,7 @@ rust_library( "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", "//willow/crypto:kahe_traits", + "//willow/crypto:share_cipher", "//willow/crypto:shell_ahe", "//willow/crypto:shell_kahe", "//willow/crypto:shell_parameters", @@ -137,6 +138,8 @@ cc_library( "@abseil-cpp//absl/memory", "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings:string_view", + "@abseil-cpp//absl/types:span", "//ffi_utils:cxx_utils", "//ffi_utils:status_macros", "//willow/input_encoding:codec", diff --git a/willow/testing_utils/shell_testing_decryptor.cc b/willow/testing_utils/shell_testing_decryptor.cc index 8ec0616..5a1e19f 100644 --- a/willow/testing_utils/shell_testing_decryptor.cc +++ b/willow/testing_utils/shell_testing_decryptor.cc @@ -25,6 +25,8 @@ #include "absl/memory/memory.h" #include "absl/status/status.h" #include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" #include "ffi_utils/cxx_utils.h" #include "ffi_utils/status_macros.h" #include "willow/input_encoding/codec.h" @@ -74,6 +76,74 @@ ShellTestingDecryptor::CreateSetupContribution() { return contribution; } +absl::StatusOr ShellTestingDecryptor::GenerateHpkePublicKey() { + rust::Vec out; + SECAGG_RETURN_IF_FFI_ERROR(decryptor_->generate_hpke_public_key(&out)); + return std::string(reinterpret_cast(out.data()), out.size()); +} + +absl::StatusOr +ShellTestingDecryptor::BuildCommitteeConfig( + uint32_t my_index, uint32_t num_decryptors, uint32_t threshold, + absl::string_view session_context, + absl::Span roster_hpke_public_keys) { + std::vector views; + views.reserve(roster_hpke_public_keys.size()); + for (const auto& pk : roster_hpke_public_keys) { + views.push_back(ByteSliceView{ToRustSlice(pk)}); + } + rust::Slice views_slice(views.data(), views.size()); + + rust::Vec out; + SECAGG_RETURN_IF_FFI_ERROR( + build_committee_config(my_index, num_decryptors, threshold, + ToRustSlice(session_context), views_slice, &out)); + + willow::CommitteeConfigProto config_proto; + if (!config_proto.ParseFromArray(out.data(), out.size())) { + return absl::InternalError("Failed to parse CommitteeConfigProto"); + } + return config_proto; +} + +absl::StatusOr +ShellTestingDecryptor::CreateRecoverableSetupContribution( + const willow::CommitteeConfigProto& committee_config, + absl::Span roster_hpke_public_keys) { + std::string config_bytes = committee_config.SerializeAsString(); + std::vector views; + views.reserve(roster_hpke_public_keys.size()); + for (const auto& pk : roster_hpke_public_keys) { + views.push_back(ByteSliceView{ToRustSlice(pk)}); + } + rust::Slice views_slice(views.data(), views.size()); + + rust::Vec out; + SECAGG_RETURN_IF_FFI_ERROR(decryptor_->create_recoverable_setup_contribution( + ToRustSlice(config_bytes), views_slice, &out)); + + willow::SetupContribution contribution; + if (!contribution.ParseFromArray(out.data(), out.size())) { + return absl::InternalError("Failed to parse recoverable SetupContribution"); + } + return contribution; +} + +absl::StatusOr +ShellTestingDecryptor::HandleRecoveryRequest( + const willow::RecoveryRequest& request) { + std::string req_bytes = request.SerializeAsString(); + rust::Vec out; + SECAGG_RETURN_IF_FFI_ERROR( + decryptor_->handle_recovery_request(ToRustSlice(req_bytes), &out)); + + willow::RecoveryResponse response; + if (!response.ParseFromArray(out.data(), out.size())) { + return absl::InternalError("Failed to parse RecoveryResponse"); + } + return response; +} + absl::StatusOr ShellTestingDecryptor::VerifyAndAggregateKeyContributions( const willow::VerifyKeyContributionsRequest& request) { diff --git a/willow/testing_utils/shell_testing_decryptor.h b/willow/testing_utils/shell_testing_decryptor.h index 288409f..d83b152 100644 --- a/willow/testing_utils/shell_testing_decryptor.h +++ b/willow/testing_utils/shell_testing_decryptor.h @@ -17,10 +17,13 @@ #ifndef SECURE_AGGREGATION_WILLOW_TESTING_UTILS_SHELL_TESTING_DECRYPTOR_H_ #define SECURE_AGGREGATION_WILLOW_TESTING_UTILS_SHELL_TESTING_DECRYPTOR_H_ +#include #include #include #include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/types/span.h" #include "willow/input_encoding/codec.h" #include "willow/proto/shell/ciphertexts.pb.h" #include "willow/proto/willow/aggregation_config.pb.h" @@ -46,6 +49,34 @@ class ShellTestingDecryptor { // Generates a setup contribution containing key share and key gen proof. absl::StatusOr CreateSetupContribution(); + // Generates a 32-byte raw X25519 HPKE public key for recoverable committee + // secret sharing and stores the corresponding HPKE keypair in this decryptor. + absl::StatusOr GenerateHpkePublicKey(); + + // Builds a validated CommitteeConfigProto for `my_index` (1-based in + // `1..=num_decryptors`) given the ordered 1..=num_decryptors roster of raw + // 32-byte HPKE public keys (`roster_hpke_public_keys[i - 1]` for index `i`), + // computing the canonical SHA-256 `roster_hash`. + static absl::StatusOr BuildCommitteeConfig( + uint32_t my_index, uint32_t num_decryptors, uint32_t threshold, + absl::string_view session_context, + absl::Span roster_hpke_public_keys); + + // Generates a recoverable committee setup contribution containing the + // 1-based `decryptor_index`, `FeldmanCommitment`, `KeyContribution` (derived + // deterministically from the Feldman VSS secret), and HPKE-encrypted shares + // for all peer committee members (`{1..=num_decryptors} \ {my_index}`). + // Requires `GenerateHpkePublicKey()` to have been called first on this + // decryptor. + absl::StatusOr CreateRecoverableSetupContribution( + const willow::CommitteeConfigProto& committee_config, + absl::Span roster_hpke_public_keys); + + // Handles a one-shot RecoveryRequest from the Coordinator to decrypt Feldman + // VSS shares of dropped committee members. + absl::StatusOr HandleRecoveryRequest( + const willow::RecoveryRequest& request); + // Verifies key generation proofs and aggregates key contributions into public // key. absl::StatusOr VerifyAndAggregateKeyContributions( diff --git a/willow/testing_utils/shell_testing_decryptor.rs b/willow/testing_utils/shell_testing_decryptor.rs index ea67fe1..d4d81d1 100644 --- a/willow/testing_utils/shell_testing_decryptor.rs +++ b/willow/testing_utils/shell_testing_decryptor.rs @@ -20,20 +20,25 @@ use ahe_traits::AheBase; use decryptor::{Decryptor, DecryptorState}; use kahe_traits::{KaheBase, KaheDecrypt, TrySecretKeyFrom}; use messages::{ - ClientMessage, PartialDecryptionRequest, PartialDecryptionResponse, - VerifyKeyContributionsRequest, + compute_roster_hash, ClientMessage, CommitteeConfig, PartialDecryptionRequest, + PartialDecryptionResponse, RecoveryRequest, RecoveryResponse, VerifyKeyContributionsRequest, }; use messages_rust_proto::ClientMessage as ClientMessageProto; +use messages_rust_proto::CommitteeConfigProto; use messages_rust_proto::PartialDecryptionRequest as PartialDecryptionRequestProto; +use messages_rust_proto::RecoveryRequest as RecoveryRequestProto; use messages_rust_proto::VerifyKeyContributionsRequest as VerifyKeyContributionsRequestProto; use proto_serialization_traits::{FromProto, ToProto}; use protobuf::prelude::*; +use share_cipher::{ShareCipher, TinkHpkePrivateKey, TinkHpkePublicKey, TinkHpkeShareCipher}; use shell_ahe::Ciphertext as VaheCiphertext; use shell_kahe::Ciphertext as KaheCiphertext; use shell_kahe::ShellKahe; use shell_parameters::create_shell_configs; use shell_vahe::ShellVahe; use status::StatusError; +use std::collections::BTreeMap; +use std::num::NonZeroU32; use std::rc::Rc; use vahe_traits::{HasVahe, Recover, VaheBase}; @@ -49,6 +54,7 @@ pub struct ShellTestingDecryptor { kahe: Rc, decryptor: Decryptor, state: DecryptorState, + hpke_keypair: Option<(TinkHpkePrivateKey, TinkHpkePublicKey)>, } impl HasVahe for ShellTestingDecryptor { @@ -76,12 +82,9 @@ impl ShellTestingDecryptor { let kahe = Rc::new(ShellKahe::new(kahe_config, context_bytes)?); let vahe = Rc::new(ShellVahe::new(ahe_config, context_bytes)?); let decryptor = Decryptor::new_with_randomly_generated_seed(vahe)?; - let state = DecryptorState { - sk_share: None, - kahe: None, - aggregation_config: Some(aggregation_config.clone()), - }; - Ok(ShellTestingDecryptor { kahe, decryptor, state }) + let mut state = DecryptorState::default(); + state.aggregation_config = Some(aggregation_config.clone()); + Ok(ShellTestingDecryptor { kahe, decryptor, state, hpke_keypair: None }) } /// Generates a new AHE public key, and stores the corresponding secret key share in @@ -187,12 +190,127 @@ impl ShellTestingDecryptor { self.create_setup_contribution_serialized().map(|res| unsafe { *out = res }).into() } + /// Generates an HPKE keypair for recoverable committee secret sharing, stores it in + /// `self.hpke_keypair`, and returns the 32-byte raw X25519 public key. + pub fn generate_hpke_public_key(&mut self) -> Result, StatusError> { + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + let pk_bytes = pk.as_bytes().to_vec(); + self.hpke_keypair = Some((sk, pk)); + Ok(pk_bytes) + } + + /// SAFETY: `out` must be valid for writes. + unsafe fn generate_hpke_public_key_ffi(&mut self, out: *mut Vec) -> ffi::FfiStatus { + self.generate_hpke_public_key().map(|res| unsafe { *out = res }).into() + } + + /// Creates a recoverable committee setup contribution. + pub fn create_recoverable_setup_contribution( + &mut self, + committee_config: CommitteeConfig, + roster_pks: &BTreeMap, + ) -> Result, StatusError> { + let (hpke_sk, _) = self.hpke_keypair.as_ref().ok_or_else(|| { + status::failed_precondition( + "ShellTestingDecryptor has no HPKE keypair (call GenerateHpkePublicKey first)", + ) + })?; + self.decryptor.create_recoverable_setup_contribution( + committee_config, + roster_pks, + hpke_sk, + &mut self.state, + ) + } + + fn create_recoverable_setup_contribution_serialized( + &mut self, + committee_config_bytes: &[u8], + roster_views: &[ffi::ByteSliceView], + ) -> Result, StatusError> { + let cfg_proto = CommitteeConfigProto::parse(committee_config_bytes).map_err(|e| { + status::invalid_argument(&format!("Failed to parse CommitteeConfigProto: {}", e)) + })?; + let cfg = CommitteeConfig::from_proto(cfg_proto, ())?; + if roster_views.len() != cfg.num_decryptors as usize { + return Err(status::invalid_argument( + "roster_hpke_public_keys count must equal committee_config.num_decryptors", + )); + } + let mut roster_pks = BTreeMap::new(); + for (i, view) in roster_views.iter().enumerate() { + let idx = NonZeroU32::new((i + 1) as u32) + .ok_or_else(|| status::internal("1-based roster index must be non-zero"))?; + let pk = TinkHpkePublicKey::from_bytes(view.data.to_vec())?; + roster_pks.insert(idx, pk); + } + let contrib = self.create_recoverable_setup_contribution(cfg, &roster_pks)?; + let proto = contrib.to_proto(self)?; + proto.serialize().map_err(|e| status::internal(&format!("Serialize error: {}", e))) + } + + /// SAFETY: `out` must be valid for writes. + unsafe fn create_recoverable_setup_contribution_ffi( + &mut self, + committee_config_bytes: &[u8], + roster_views: &[ffi::ByteSliceView], + out: *mut Vec, + ) -> ffi::FfiStatus { + self.create_recoverable_setup_contribution_serialized(committee_config_bytes, roster_views) + .map(|res| unsafe { *out = res }) + .into() + } + + /// Handles a one-shot recovery request from the Coordinator. + pub fn handle_recovery_request( + &mut self, + request: RecoveryRequest, + ) -> Result { + self.decryptor.handle_recovery_request(request, &mut self.state) + } + + fn handle_recovery_request_serialized( + &mut self, + request_bytes: &[u8], + ) -> Result, StatusError> { + let req_proto = RecoveryRequestProto::parse(request_bytes).map_err(|e| { + status::invalid_argument(&format!("Failed to parse RecoveryRequestProto: {}", e)) + })?; + let req = RecoveryRequest::from_proto(req_proto, ())?; + let resp = self.handle_recovery_request(req)?; + let proto = resp.to_proto(())?; + proto.serialize().map_err(|e| status::internal(&format!("Serialize error: {}", e))) + } + + /// SAFETY: `out` must be valid for writes. + unsafe fn handle_recovery_request_ffi( + &mut self, + request_bytes: &[u8], + out: *mut Vec, + ) -> ffi::FfiStatus { + self.handle_recovery_request_serialized(request_bytes) + .map(|res| unsafe { *out = res }) + .into() + } + /// Verifies key generation proofs and aggregates key contributions into a public key. + /// + /// When this `ShellTestingDecryptor` has already generated a setup contribution + /// (`self.state.pk_share.is_some()`), enforces that its own `public_key_share` is + /// included in `request` via [`Decryptor::verify_and_aggregate_key_contributions_with_state`]. pub fn verify_and_aggregate_key_contributions( &mut self, request: VerifyKeyContributionsRequest, ) -> Result<::PublicKey, StatusError> { - self.decryptor.verify_and_aggregate_key_contributions(request) + if self.state.pk_share.is_some() { + self.decryptor.verify_and_aggregate_key_contributions_with_state( + request, + &self.state, + 1, + ) + } else { + self.decryptor.verify_and_aggregate_key_contributions(request) + } } fn verify_and_aggregate_key_contributions_serialized( @@ -273,6 +391,10 @@ pub mod ffi { values: Vec, } + struct ByteSliceView<'a> { + data: &'a [u8], + } + unsafe extern "C++" { include!("ffi_utils/status.rs.h"); #[namespace = "secure_aggregation"] @@ -288,6 +410,15 @@ pub mod ffi { out: *mut *mut ShellTestingDecryptor, ) -> FfiStatus; + unsafe fn build_committee_config( + my_index: u32, + num_decryptors: u32, + threshold: u32, + session_context: &[u8], + roster_views: &[ByteSliceView], + out: *mut Vec, + ) -> FfiStatus; + #[rust_name = "generate_public_key_ffi"] unsafe fn generate_public_key( self: &mut ShellTestingDecryptor, @@ -300,6 +431,27 @@ pub mod ffi { out: *mut Vec, ) -> FfiStatus; + #[rust_name = "generate_hpke_public_key_ffi"] + unsafe fn generate_hpke_public_key( + self: &mut ShellTestingDecryptor, + out: *mut Vec, + ) -> FfiStatus; + + #[rust_name = "create_recoverable_setup_contribution_ffi"] + unsafe fn create_recoverable_setup_contribution( + self: &mut ShellTestingDecryptor, + committee_config_bytes: &[u8], + roster_views: &[ByteSliceView], + out: *mut Vec, + ) -> FfiStatus; + + #[rust_name = "handle_recovery_request_ffi"] + unsafe fn handle_recovery_request( + self: &mut ShellTestingDecryptor, + request: &[u8], + out: *mut Vec, + ) -> FfiStatus; + #[rust_name = "verify_and_aggregate_key_contributions_ffi"] unsafe fn verify_and_aggregate_key_contributions( self: &mut ShellTestingDecryptor, @@ -347,6 +499,53 @@ unsafe fn create_shell_testing_decryptor( .into() } +fn build_committee_config_impl( + my_index: u32, + num_decryptors: u32, + threshold: u32, + session_context: &[u8], + roster_views: &[ffi::ByteSliceView], +) -> Result, StatusError> { + let my_idx = NonZeroU32::new(my_index).ok_or_else(|| { + status::invalid_argument("build_committee_config: my_index must be non-zero") + })?; + if roster_views.len() != num_decryptors as usize { + return Err(status::invalid_argument( + "build_committee_config: roster_hpke_public_keys count must equal num_decryptors", + )); + } + let mut raw_roster = BTreeMap::new(); + for (i, view) in roster_views.iter().enumerate() { + let idx = NonZeroU32::new((i + 1) as u32) + .ok_or_else(|| status::internal("1-based roster index must be non-zero"))?; + raw_roster.insert(idx, view.data.to_vec()); + } + let roster_hash = compute_roster_hash(num_decryptors, threshold, &raw_roster)?; + let cfg = CommitteeConfig::new( + my_idx, + num_decryptors, + threshold, + roster_hash, + session_context.to_vec(), + )?; + let proto = cfg.to_proto(())?; + proto.serialize().map_err(|e| status::internal(&format!("Serialize error: {}", e))) +} + +/// SAFETY: `out` must be valid for writes. +unsafe fn build_committee_config( + my_index: u32, + num_decryptors: u32, + threshold: u32, + session_context: &[u8], + roster_views: &[ffi::ByteSliceView], + out: *mut Vec, +) -> ffi::FfiStatus { + build_committee_config_impl(my_index, num_decryptors, threshold, session_context, roster_views) + .map(|bytes| unsafe { *out = bytes }) + .into() +} + /// Converts a raw pointer to a Box. Ideally we would use `rust::Box::from_raw` /// (https://cxx.rs/binding/box.html) directly from C++, but that causes linker errors. /// diff --git a/willow/tests/BUILD b/willow/tests/BUILD index 75bb63d..b0a6c39 100644 --- a/willow/tests/BUILD +++ b/willow/tests/BUILD @@ -29,8 +29,10 @@ rust_test( "//ffi_utils:status_matchers_rs", "//willow/api:proto_serialization_traits", "//willow/crypto:ahe_traits", + "//willow/crypto:feldman_vss", "//willow/crypto:kahe_traits", "//willow/crypto:prng_traits", + "//willow/crypto:share_cipher", "//willow/crypto:shell_ahe", "//willow/crypto:shell_kahe", "//willow/crypto:shell_parameters", diff --git a/willow/tests/e2e_shell.rs b/willow/tests/e2e_shell.rs index aaffe3f..4c327b1 100644 --- a/willow/tests/e2e_shell.rs +++ b/willow/tests/e2e_shell.rs @@ -25,17 +25,23 @@ use googletest::prelude::container_eq; use googletest::{gtest, verify_eq, verify_that}; use kahe_traits::KaheBase; use messages::{ - CiphertextContribution, ClientMessage, CoordinatorState, DecryptionRequestContribution, - PartialDecryptionRequest, PartialDecryptionResponse, + compute_roster_hash, CiphertextContribution, ClientMessage, CommitteeConfig, CoordinatorState, + CoordinatorStatus, DecryptionRequestContribution, DecryptorStatus, PartialDecryptionRequest, + PartialDecryptionResponse, RecoveryRequest, RecoveryResponse, SetupContribution, }; use proto_serialization_traits::{FromProto, ToProto}; +use share_cipher::{ + ShareCipher, TinkHpkeKeysetWrappingKey, TinkHpkePrivateKey, TinkHpkePublicKey, + TinkHpkeShareCipher, +}; use shell_kahe::ShellKahe; use shell_parameters::{create_shell_ahe_config, create_shell_kahe_config}; use shell_vahe::ShellVahe; use single_decryptor::SingleDecryptor; use status::StatusErrorCode; use status_matchers_rs::status_is; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::num::NonZeroU32; use std::rc::Rc; use testing_utils::{ generate_aggregation_config, generate_random_nonce, generate_random_unsigned_vector, @@ -486,10 +492,7 @@ fn encrypt_decrypt_many_clients_decryptors() -> googletest::Result<()> { MAX_NUM_DECRYPTORS, MAX_NUM_CLIENTS, ); - // The parameters used by `create_shell_ahe_config` below is secure for only a single - // decryptor when assuming all clients are corrupted. As we are testing functionality - // here anyway, we set `max_number_of_decryptors` to 1 just to obtain the AHE parameters. - let max_number_of_decryptors = 1; + let max_number_of_decryptors = aggregation_config.max_number_of_decryptors; // Create common KAHE/VAHE instances. let vahe = Rc::new( @@ -616,10 +619,7 @@ fn encrypt_decrypt_no_dropout() -> googletest::Result<()> { let default_id = String::from("default"); let aggregation_config = generate_aggregation_config(default_id.clone(), 16, 10, NUM_DECRYPTORS, NUM_CLIENTS); - // The parameters used by `create_shell_ahe_config` below is secure for only a single - // decryptor when assuming all clients are corrupted. As we are testing functionality - // here anyway, we set `max_number_of_decryptors` to 1 just to obtain the AHE parameters. - let max_number_of_decryptors = 1; + let max_number_of_decryptors = aggregation_config.max_number_of_decryptors; // Create common KAHE/VAHE instances. let vahe = Rc::new(ShellVahe::new( @@ -739,3 +739,221 @@ fn encrypt_decrypt_no_dropout() -> googletest::Result<()> { expected_output ) } + +/// End-to-end recoverable committee test with multiple clients, 5 decryptors (t = 3), +/// 2 decryptor dropouts during partial decryption, full Feldman VSS + Tink HPKE share +/// recovery, and proto serialization round-trips for DecryptorState (using +/// set_encrypted_hpke_private_key / restore_encrypted_hpke_private_key), CoordinatorState, +/// SetupContribution, RecoveryRequest, and RecoveryResponse. +#[gtest] +fn encrypt_decrypt_recoverable_committee_with_dropouts_and_serialized_state( +) -> googletest::Result<()> { + const NUM_CLIENTS: i64 = 6; + const NUM_DECRYPTORS: u32 = 5; + const THRESHOLD: u32 = 3; + let default_id = String::from("default"); + let aggregation_config = generate_aggregation_config( + default_id.clone(), + 16, + 10, + (NUM_DECRYPTORS + 1) as i64, + NUM_CLIENTS, + ); + let max_number_of_decryptors = aggregation_config.max_number_of_decryptors; + + let vahe = Rc::new(ShellVahe::new( + create_shell_ahe_config(max_number_of_decryptors)?, + CONTEXT_STRING, + )?); + let kahe = + Rc::new(ShellKahe::new(create_shell_kahe_config(&aggregation_config)?, CONTEXT_STRING)?); + + let mut hpke_sks: BTreeMap = BTreeMap::new(); + let mut roster_pks: BTreeMap = BTreeMap::new(); + let mut raw_roster = BTreeMap::new(); + for idx_raw in 1..=NUM_DECRYPTORS { + let idx = NonZeroU32::new(idx_raw).unwrap(); + let (sk, pk) = TinkHpkeShareCipher::generate_keypair()?; + raw_roster.insert(idx, pk.as_bytes().to_vec()); + hpke_sks.insert(idx, sk); + roster_pks.insert(idx, pk); + } + let roster_hash = compute_roster_hash(NUM_DECRYPTORS, THRESHOLD, &raw_roster)?; + let session_context = CONTEXT_STRING.to_vec(); + let wrapping_key = TinkHpkeKeysetWrappingKey::generate()?; + let wrapping_aad = b"e2e_decryptor_state_aad"; + + // Create decryptors and recoverable setup contributions, exercising + // set_encrypted_hpke_private_key + to_proto + from_proto + restore_encrypted_hpke_private_key. + let mut decryptors = Vec::with_capacity(NUM_DECRYPTORS as usize); + let mut decryptor_states = Vec::with_capacity(NUM_DECRYPTORS as usize); + let mut setup_contributions = Vec::with_capacity(NUM_DECRYPTORS as usize); + for i in 0..(NUM_DECRYPTORS as usize) { + let idx = NonZeroU32::new((i + 1) as u32).unwrap(); + let decryptor = Decryptor::new_with_randomly_generated_seed(Rc::clone(&vahe))?; + let mut state = DecryptorState::default(); + let cfg = CommitteeConfig::new( + idx, + NUM_DECRYPTORS, + THRESHOLD, + roster_hash, + session_context.clone(), + )?; + let contrib = decryptor.create_recoverable_setup_contribution( + cfg, + &roster_pks, + &hpke_sks[&idx], + &mut state, + )?; + + // Wrap the HPKE private key before persisting DecryptorState via to_proto. + state.set_encrypted_hpke_private_key(&hpke_sks[&idx], &wrapping_key, wrapping_aad)?; + let state_proto = state.to_proto(&decryptor)?; + let mut restored_state = DecryptorState::from_proto(state_proto, &decryptor)?; + restored_state.restore_encrypted_hpke_private_key(&wrapping_key, wrapping_aad)?; + + // Round-trip SetupContribution through proto. + let contrib_proto = contrib.to_proto(&decryptor)?; + let restored_contrib = SetupContribution::from_proto(contrib_proto, &decryptor)?; + + decryptors.push(decryptor); + decryptor_states.push(restored_state); + setup_contributions.push(restored_contrib); + } + + // Create the Reputable Decryptor (required in recoverable committee mode). + let reputable_decryptor = Decryptor::new_with_randomly_generated_seed(Rc::clone(&vahe))?; + let mut reputable_state = DecryptorState::default(); + let reputable_contrib = reputable_decryptor.create_setup_contribution(&mut reputable_state)?; + + // Create coordinator, accumulator, and verifier. + let coord = Coordinator { vahe: Rc::clone(&vahe) }; + let mut coord_state = CoordinatorState::default(); + let coord_committee_config = CommitteeConfig::new( + NonZeroU32::new(1).unwrap(), + NUM_DECRYPTORS, + THRESHOLD, + roster_hash, + session_context, + )?; + coord.configure_committee(coord_committee_config, &mut coord_state)?; + + let verify_request = coord.handle_setup_submissions( + setup_contributions, + vec![reputable_contrib], + &mut coord_state, + )?; + let coord_state_proto = coord_state.to_proto(&coord)?; + let mut coord_state = CoordinatorState::from_proto(coord_state_proto, &coord)?; + + let public_key = reputable_decryptor.verify_and_aggregate_key_contributions_with_state( + verify_request, + &reputable_state, + (NUM_DECRYPTORS + 1) as usize, + )?; + + let accumulator = + DefaultCiphertextAccumulator { kahe: Rc::clone(&kahe), vahe: Rc::clone(&vahe) }; + let mut accumulator_state = CiphertextAccumulatorState::default(); + let verifier = DefaultVerifier { vahe: Rc::clone(&vahe) }; + let mut verifier_state = VerifierState::default(); + + let mut expected_output = vec![0u64; 15]; + let mut client_messages = Vec::with_capacity(NUM_CLIENTS as usize); + for _ in 0..NUM_CLIENTS { + let client = + DefaultClient::new_with_randomly_generated_seed(Rc::clone(&kahe), Rc::clone(&vahe))?; + let client_input_values = vec![1, 2, 3, 4, 5, 6, 7, 8, 7, 6, 5, 4, 3, 2, 1]; + for j in 0..expected_output.len() { + expected_output[j] += client_input_values[j]; + } + let client_plaintext = + HashMap::from([(default_id.as_str(), client_input_values.as_slice())]); + let nonce = generate_random_nonce(); + let client_message = + client.create_client_message(&client_plaintext, &public_key, &nonce)?; + client_messages.push(client_message); + } + client_messages.sort_by(|a, b| a.nonce.cmp(&b.nonce)); + + for client_message in client_messages { + let (ciphertext_contribution, decryption_request_contribution) = + accumulator.split_client_message(client_message)?; + verifier.verify_and_include(decryption_request_contribution, &mut verifier_state)?; + accumulator + .accumulate_ciphertext_contribution(ciphertext_contribution, &mut accumulator_state)?; + } + + let pd_ct = verifier.create_partial_decryption_request(verifier_state)?; + let pd_request = + coord.prepare_decryption_request(&pd_ct.partial_dec_ciphertext, &mut coord_state)?; + let coord_state_proto = coord_state.to_proto(&coord)?; + let mut coord_state = CoordinatorState::from_proto(coord_state_proto, &coord)?; + + // Decryptors 2 and 4 drop out (d = 2 <= n - t = 2); survivors are 1, 3, 5 (plus reputable). + let survivors = [1u32, 3, 5]; + let mut partial_responses = Vec::with_capacity(survivors.len() + 1); + partial_responses.push(reputable_decryptor.handle_partial_decryption_request( + pd_request.clone(), + Some(kahe.as_ref()), + &mut reputable_state, + )?); + for &idx_raw in &survivors { + let idx = NonZeroU32::new(idx_raw).unwrap(); + let i = (idx_raw - 1) as usize; + let mut pd = decryptors[i].handle_partial_decryption_request( + pd_request.clone(), + Some(kahe.as_ref()), + &mut decryptor_states[i], + )?; + pd.decryptor_index = Some(idx); + verify_eq!(decryptor_states[i].status, DecryptorStatus::AwaitingRecoveryRequest)?; + + // Round-trip DecryptorState in AwaitingRecoveryRequest. + let state_proto = decryptor_states[i].to_proto(&decryptors[i])?; + let mut restored_state = DecryptorState::from_proto(state_proto, &decryptors[i])?; + restored_state.restore_encrypted_hpke_private_key(&wrapping_key, wrapping_aad)?; + decryptor_states[i] = restored_state; + + partial_responses.push(pd); + } + + coord.aggregate_partial_decryptions( + partial_responses, + Some(kahe.as_ref()), + &mut coord_state, + )?; + verify_eq!(coord_state.status, CoordinatorStatus::AwaitingRecovery)?; + + // Round-trip CoordinatorState in AwaitingRecovery. + let coord_state_proto = coord_state.to_proto(&coord)?; + let mut coord_state = CoordinatorState::from_proto(coord_state_proto, &coord)?; + + let indexed_recovery_requests = coord.create_indexed_recovery_requests(&coord_state)?; + verify_eq!(indexed_recovery_requests.len(), 3)?; + + let mut recovery_responses = Vec::with_capacity(indexed_recovery_requests.len()); + for (survivor_idx, req) in indexed_recovery_requests { + let req_proto = req.to_proto(())?; + let req = RecoveryRequest::from_proto(req_proto, ())?; + let i = (survivor_idx.get() - 1) as usize; + let resp = decryptors[i].handle_recovery_request(req, &mut decryptor_states[i])?; + verify_eq!(decryptor_states[i].status, DecryptorStatus::Finished)?; + let resp_proto = resp.to_proto(())?; + let resp = RecoveryResponse::from_proto(resp_proto, ())?; + recovery_responses.push(resp); + } + + coord.recover_dropped_decryptors(recovery_responses, &mut coord_state)?; + verify_eq!(coord_state.status, CoordinatorStatus::OutputReady)?; + + let finalized_pd = coord.finalize_partial_decryption(&mut coord_state)?; + let aggregation_result = + accumulator.recover_aggregation_result(&accumulator_state, &finalized_pd)?; + + verify_that!(aggregation_result.keys().collect::>(), container_eq([&default_id]))?; + verify_eq!( + aggregation_result.get(default_id.as_str()).unwrap()[..expected_output.len()], + expected_output + ) +}