Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/pq-endorsement-chain.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Services with an ML-DSA identity endorse the ML-DSA identity of the previous service when they open, or their own when the previous service had none, in `previous_service_identity_endorsement[PQ]` next to the classical endorsement; see the [built-in maps reference](doc/audit/builtin_maps.rst).
8 changes: 7 additions & 1 deletion doc/audit/builtin_maps.rst
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,13 @@ While the contents themselves are encrypted, the table is public so as to be acc
``previous_service_identity_endorsement``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**Key** Sentinel value 0, represented as a little-endian 64-bit unsigned integer.
Endorsements of service identities by the identities that succeed them, keyed by service signing identity type.
When a service opens, it signs with the key of each identity type an endorsement of the key that signed the previous endorsement of that type, or of its own key if there is none, so that each type chains service identities across disaster recoveries.

**Key** Identity type as a little-endian 64-bit unsigned integer.
``CLASSICAL`` (0) is written by every service.
``PQ`` (1) is written by a service with an ML-DSA identity, in the same transaction.
It endorses the previous ``PQ`` endorsing key over the same epochs, and with the same previous Merkle root, as ``CLASSICAL``, or its own key if the previous service had no ML-DSA identity.

**Value**

Expand Down
2 changes: 2 additions & 0 deletions doc/releases/migration_7_x_to_8_x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ Nodes that join a service never create one.
- The ML-DSA service certificate is renewed by the ``set_pq_service_certificate_validity`` proposal of the 8.x default constitution (see :ref:`governance/common_member_operations:Renewing ML-DSA Service Certificate`).
A recovery keeps the constitution stored in the ledger, so under a 7.x or custom constitution the ML-DSA certificate expires after ``initial_service_certificate_validity_days``.
After the recovery that introduces the identity and before the first renewal, install the 8.x default constitution with ``set_constitution``, or port its ``set_pq_service_certificate_validity`` action into the custom constitution.
- When a service with an ML-DSA identity opens, it endorses the ML-DSA identity of the previous service with its own, as it does for the classical identity (see :ref:`audit/builtin_maps:``previous_service_identity_endorsement```).
A service whose previous service had no ML-DSA identity, such as the one that introduces it, endorses its own ML-DSA identity, which starts the chain.
80 changes: 54 additions & 26 deletions src/crypto/cose.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,41 @@ namespace ccf::cose
make_string(header::custom::CCF_V1), make_map(std::move(ccf_entries)));
return make_map(std::move(phdr));
}

tav::cbor::Value endorsement_protected_header(
int64_t alg,
int64_t iat,
std::string_view epoch_begin,
std::string_view epoch_end,
std::span<const uint8_t> previous_merkle_root)
{
using namespace tav::cbor;
std::vector<MapItem> cwt_entries;
cwt_entries.emplace_back(
make_signed(cwt::header::iana::IAT), make_signed(iat));
std::vector<MapItem> ccf_entries;
ccf_entries.emplace_back(
make_string(header::custom::TX_RANGE_BEGIN), make_string(epoch_begin));
if (!epoch_end.empty())
{
ccf_entries.emplace_back(
make_string(header::custom::TX_RANGE_END), make_string(epoch_end));
}
if (!previous_merkle_root.empty())
{
ccf_entries.emplace_back(
make_string(header::custom::EPOCH_LAST_MERKLE_ROOT),
make_bytes(previous_merkle_root));
}
std::vector<MapItem> phdr;
phdr.emplace_back(make_signed(header::iana::ALG), make_signed(alg));
phdr.emplace_back(
make_signed(header::iana::CWT_CLAIMS),
make_map(std::move(cwt_entries)));
phdr.emplace_back(
make_string(header::custom::CCF_V1), make_map(std::move(ccf_entries)));
return make_map(std::move(phdr));
}
}

SigningAlgorithm algorithm_for_curve(crypto::CurveID curve)
Expand Down Expand Up @@ -240,35 +275,28 @@ namespace ccf::cose
std::span<const uint8_t> previous_merkle_root,
std::span<const uint8_t> payload)
{
using namespace tav::cbor;
const auto algorithm = algorithm_for_curve(key.get_curve_id());
std::vector<MapItem> cwt_entries;
cwt_entries.emplace_back(
make_signed(cwt::header::iana::IAT), make_signed(iat));
std::vector<MapItem> ccf_entries;
ccf_entries.emplace_back(
make_string(header::custom::TX_RANGE_BEGIN), make_string(epoch_begin));
if (!epoch_end.empty())
{
ccf_entries.emplace_back(
make_string(header::custom::TX_RANGE_END), make_string(epoch_end));
}
if (!previous_merkle_root.empty())
{
ccf_entries.emplace_back(
make_string(header::custom::EPOCH_LAST_MERKLE_ROOT),
make_bytes(previous_merkle_root));
}
std::vector<MapItem> phdr;
phdr.emplace_back(
make_signed(header::iana::ALG), make_signed(algorithm.alg));
phdr.emplace_back(
make_signed(header::iana::CWT_CLAIMS), make_map(std::move(cwt_entries)));
phdr.emplace_back(
make_string(header::custom::CCF_V1), make_map(std::move(ccf_entries)));
return sign1(
ecdsa_signer(key, algorithm.md),
make_map(std::move(phdr)),
endorsement_protected_header(
algorithm.alg, iat, epoch_begin, epoch_end, previous_merkle_root),
payload,
false);
}

std::vector<uint8_t> sign_endorsement(
const crypto::MLDSAKeyPair& key,
int64_t iat,
std::string_view epoch_begin,
std::string_view epoch_end,
std::span<const uint8_t> previous_merkle_root,
std::span<const uint8_t> payload)
{
const auto alg = algorithm_for_parameter_set(key.get_parameter_set());
return sign1(
mldsa_signer(key),
endorsement_protected_header(
alg, iat, epoch_begin, epoch_end, previous_merkle_root),
payload,
false);
}
Expand Down
12 changes: 12 additions & 0 deletions src/crypto/cose.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ namespace ccf
std::span<const uint8_t> previous_merkle_root,
std::span<const uint8_t> payload);

/// Same protected header except alg, signed with pure ML-DSA over the
/// Sig_structure and an empty context (RFC 9964).
/// @throws std::runtime_error if header construction, signing, or
/// encoding fails.
std::vector<uint8_t> sign_endorsement(
const crypto::MLDSAKeyPair& key,
int64_t iat,
std::string_view epoch_begin,
std::string_view epoch_end,
std::span<const uint8_t> previous_merkle_root,
std::span<const uint8_t> payload);

namespace header
{
namespace iana // https://www.iana.org/assignments/cose/cose.xhtml
Expand Down
82 changes: 82 additions & 0 deletions src/crypto/test/mldsa.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,88 @@ TEST_CASE("ML-DSA COSE_Sign1 ledger signatures")
}
}

TEST_CASE("ML-DSA COSE_Sign1 endorsements")
{
using namespace tav::cbor;
using ccf::cose::header::iana::ALG;
const std::vector<uint8_t> root(32, 0xab);
const auto ec_key = make_ec_key_pair(CurveID::SECP384R1);
const auto protected_header = [](const std::vector<uint8_t>& envelope) {
const auto phdr = nondet_parse(envelope)
.tag_at(ccf::cbor::tag::COSE_SIGN_1)
.array_at(0)
.as_bytes();
return std::vector<uint8_t>(phdr.begin(), phdr.end());
};

for (const auto& info : PARAMETER_SETS)
{
SUBCASE(info.name)
{
auto key = make_mldsa_key_pair(info.id);
const auto endorsed = make_mldsa_key_pair(info.id)->public_key_der();
const auto cert =
key->self_sign("CN=CCF Service", "20260101000000Z", "20270101000000Z");

const auto check = [&](
std::string_view epoch_end,
std::span<const uint8_t> previous_root) {
const auto envelope = ccf::cose::sign_endorsement(
*key, 1700000000, "2.1", epoch_end, previous_root, endorsed);
const auto ec_envelope = ccf::cose::sign_endorsement(
*ec_key, 1700000000, "2.1", epoch_end, previous_root, endorsed);

// Only the algorithm differs from the classical protected header
const auto phdr_bytes = protected_header(envelope);
const auto ec_phdr_bytes = protected_header(ec_envelope);
CHECK(std::ranges::equal(
ccf::cbor::with_entry(
nondet_parse(ec_phdr_bytes), ALG, make_signed(info.cose_alg))
.det_serialize(),
phdr_bytes));

const auto fields =
nondet_parse(envelope).tag_at(ccf::cbor::tag::COSE_SIGN_1);
REQUIRE(fields.size() == 4);
CHECK(fields.array_at(1).size() == 0);
CHECK(std::ranges::equal(fields.array_at(2).as_bytes(), endorsed));
CHECK(fields.array_at(3).as_bytes().size() == info.signature_size);

// The endorsing key as stored with endorsements (SPKI), as published
// (AKP COSE_Key), and as a certificate
const COSEKey cose_key(make_mldsa_public_key(key->public_key_der()));
for (const auto& verifier :
{make_cose_verifier_from_key(key->public_key_der()),
make_cose_verifier_from_key(
COSEKey::from_cbor(cose_key.to_cbor(info.cose_alg))),
make_cose_verifier_from_pem_cert(cert)})
{
std::span<uint8_t> authenticated;
REQUIRE(verifier->verify(envelope, authenticated));
CHECK(std::ranges::equal(authenticated, endorsed));
CHECK_FALSE(verifier->verify(ec_envelope, authenticated));
}
std::span<uint8_t> authenticated;
CHECK_FALSE(make_cose_verifier_from_key(ec_key->public_key_der())
->verify(envelope, authenticated));
CHECK_FALSE(make_cose_verifier_from_key(
make_mldsa_key_pair(info.id)->public_key_der())
->verify(envelope, authenticated));
return envelope;
};

INFO("Back-endorsement");
const auto back_endorsement = check("3.4", root);
const auto validity = extract_cose_endorsement_validity(back_endorsement);
CHECK(validity.from_txid == "2.1");
CHECK(validity.to_txid == "3.4");

INFO("Self-endorsement, with no epoch end or previous root");
check({}, {});
}
}
}

TEST_CASE("ML-DSA NIST ACVP pure sigVer known answers")
{
for (const auto& vector : mldsa_test_vectors::ACVP_SIG_VER)
Expand Down
Loading