Skip to content

Commit f6b3370

Browse files
authored
Merge branch 'main' into f/hehehe
2 parents 09d38ae + a86cec6 commit f6b3370

32 files changed

Lines changed: 1053 additions & 236 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ tests/perf-system/analyzer/*.png
4545
**/*.ipynb*
4646
scripts/azure_deployment/.env
4747
.env
48-
python/src/ccf/version.py
48+
python/src/ccf/version.py
49+
scripts/env-*

CHANGELOG.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
### Added
1313

14+
- Backup nodes can now be configured to automatically fetch snapshots from the primary when snapshot evidence is detected. This is controlled by the `snapshots.backup_fetch` configuration section, with `enabled`, `max_attempts`, `retry_interval`, `max_size` and `target_rpc_interface` options. Note that the target RPC interface selected must have the `SnapshotRead` operator feature enabled.
1415
- Added `ccf::IdentityHistoryNotFetched` exception type to distinguish identity-history-fetching errors from other logic errors in the network identity subsystem (#7708).
1516
- Added `ccf::describe_cose_receipt_v1(receipt)` to obtain COSE receipts with Merkle proof in unprotected header for non-signature TXs, and empty unprotected header for signature TXs (#7700).
1617
- `NetworkIdentitySubsystemInterface` now exposes `get_trusted_keys()`, returning all trusted network identity keys as a `TrustedKeys` map (#7690).
@@ -19,10 +20,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1920
### Changed
2021

2122
- On recovery, the UVM descriptor SVN is now set to the minimum of the previously stored value in the KV and the value found in the new node's startup endorsements. On start, the behaviour is unchanged (#7716).
22-
- Refactored the user facing surface of self-healing-open and local sealing. The whole feature is now `sealing-recovery` with `self-healing-open` now referred to as the `recovery-decision-protocol`. (#7679)
23-
- Local sealing is enabled by setting the `sealing-recovery` config field (for both the sealing node, and the unsealing recovery node)
24-
- The local sealing identity is under `sealing-recovery.location.name`
25-
- The recovery-decision-protocol is configured via `sealing-recovery.recovery_decision_protocol`
23+
- Refactored the user facing surface of self-healing-open and local sealing. The whole feature is now `sealing-recovery` with `self-healing-open` now referred to as the `recovery-decision-protocol` (#7679).
24+
- Local sealing is enabled by setting the `sealing-recovery` config field (for both the sealing node, and the unsealing recovery node).
25+
- The local sealing identity is under `sealing-recovery.location.name`.
26+
- The recovery-decision-protocol is configured via `sealing-recovery.recovery_decision_protocol`.
27+
- Snapshots now carry COSE receipts, JSON receipts are no longer included (#7711).
2628

2729
## [7.0.0-dev11]
2830

doc/host_config_schema/cchost_config.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,39 @@
498498
"read_only_directory": {
499499
"type": ["string", "null"],
500500
"description": "Path to read-only snapshots directory"
501+
},
502+
"backup_fetch": {
503+
"type": "object",
504+
"properties": {
505+
"enabled": {
506+
"type": "boolean",
507+
"default": false,
508+
"description": "If true, backup nodes will automatically fetch snapshots from the primary when snapshot evidence is detected"
509+
},
510+
"max_attempts": {
511+
"type": "integer",
512+
"default": 3,
513+
"description": "Maximum number of fetch attempts before giving up",
514+
"minimum": 1
515+
},
516+
"retry_interval": {
517+
"type": "string",
518+
"default": "1000ms",
519+
"description": "Delay between retry attempts"
520+
},
521+
"target_rpc_interface": {
522+
"type": "string",
523+
"default": "primary_rpc_interface",
524+
"description": "Name of the RPC interface on the primary node to use for downloading snapshots. Must have the SnapshotRead feature enabled."
525+
},
526+
"max_size": {
527+
"type": "string",
528+
"default": "200MB",
529+
"description": "Maximum size of snapshot this node is willing to fetch"
530+
}
531+
},
532+
"description": "Configuration for automatic snapshot fetching by backup nodes",
533+
"additionalProperties": false
501534
}
502535
},
503536
"description": "This section includes configuration for the snapshot directories and files",

include/ccf/node/startup_config.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ namespace ccf
9999
size_t tx_count = 10'000;
100100
std::optional<std::string> read_only_directory = std::nullopt;
101101

102+
struct BackupFetch
103+
{
104+
bool enabled = false;
105+
size_t max_attempts = 3;
106+
ccf::ds::TimeString retry_interval = {"1000ms"};
107+
std::string target_rpc_interface = ccf::PRIMARY_RPC_INTERFACE;
108+
ccf::ds::SizeString max_size = {"200MB"};
109+
110+
bool operator==(const BackupFetch&) const = default;
111+
};
112+
BackupFetch backup_fetch = {};
113+
102114
bool operator==(const Snapshots&) const = default;
103115
};
104116
Snapshots snapshots = {};

python/src/ccf/ledger.py

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from cryptography.x509 import load_pem_x509_certificate
1616
from cryptography.hazmat.primitives import hashes, serialization
17+
from cryptography.hazmat.backends import default_backend
1718
from cryptography.exceptions import InvalidSignature
1819
from cryptography.hazmat.primitives.asymmetric import utils, ec
1920

@@ -44,6 +45,7 @@
4445
WELL_KNOWN_SINGLETON_TABLE_KEY = bytes(bytearray(8))
4546

4647
SHA256_DIGEST_SIZE = sha256().digest_size
48+
ENCODED_COSE_SIGN1_TAG = 0xD2
4749

4850

4951
class NodeStatus(Enum):
@@ -1020,34 +1022,8 @@ def __init__(self, filename: str):
10201022
if self.is_committed() and not self.is_snapshot_file_1_x():
10211023
receipt_pos = entry_start_pos + self._header.size
10221024
receipt_bytes = _peek_all(self._file, pos=receipt_pos)
1023-
1024-
try:
1025-
receipt = json.loads(receipt_bytes.decode("utf-8"))
1026-
except json.decoder.JSONDecodeError as e:
1027-
raise InvalidSnapshotException(
1028-
f"Cannot read receipt from snapshot {os.path.basename(self._filename)}: Receipt starts at {receipt_pos} (file is {self._file_size} bytes), and contains {receipt_bytes}"
1029-
) from e
1030-
1031-
# Receipts included in snapshots always contain leaf components,
1032-
# including a claims digest and commit evidence, from 2.0.0-rc0 onwards.
1033-
# This verification code deliberately does not support snapshots
1034-
# produced by 2.0.0-dev* releases.
1035-
assert "leaf_components" in receipt
1036-
write_set_digest = bytes.fromhex(
1037-
receipt["leaf_components"]["write_set_digest"]
1038-
)
1039-
claims_digest = bytes.fromhex(receipt["leaf_components"]["claims_digest"])
1040-
commit_evidence_digest = sha256(
1041-
receipt["leaf_components"]["commit_evidence"].encode()
1042-
).digest()
1043-
leaf = (
1044-
sha256(write_set_digest + commit_evidence_digest + claims_digest)
1045-
.digest()
1046-
.hex()
1047-
)
1048-
root = ccf.receipt.root(leaf, receipt["proof"])
1049-
node_cert = load_pem_x509_certificate(receipt["cert"].encode())
1050-
ccf.receipt.verify(root, receipt["signature"], node_cert)
1025+
snapshot_digest = sha256(_peek(self._file, receipt_pos, pos=0)).digest()
1026+
self._verify_snapshot_receipt(receipt_bytes, receipt_pos, snapshot_digest)
10511027

10521028
def is_committed(self):
10531029
return COMMITTED_FILE_SUFFIX in self._filename
@@ -1061,6 +1037,88 @@ def is_snapshot_file_1_x(self):
10611037
def get_len(self) -> int:
10621038
return self._file_size
10631039

1040+
def _verify_snapshot_receipt(
1041+
self, receipt_bytes: bytes, receipt_pos: int, snapshot_digest: bytes
1042+
):
1043+
if not receipt_bytes:
1044+
raise InvalidSnapshotException("Empty snapshot receipt")
1045+
1046+
first_byte = receipt_bytes[0]
1047+
if first_byte == ENCODED_COSE_SIGN1_TAG:
1048+
self._verify_cose_snapshot_receipt(receipt_bytes, snapshot_digest)
1049+
elif first_byte == ord("{"):
1050+
self._verify_json_snapshot_receipt(
1051+
receipt_bytes, receipt_pos, snapshot_digest
1052+
)
1053+
else:
1054+
raise InvalidSnapshotException(
1055+
f"Invalid snapshot receipt: unrecognised format (first byte: 0x{first_byte:02X})"
1056+
)
1057+
1058+
def _service_public_key(self):
1059+
service_info_table = (
1060+
self.get_public_domain().get_tables().get(SERVICE_INFO_TABLE_NAME)
1061+
)
1062+
if service_info_table is None:
1063+
raise InvalidSnapshotException(
1064+
"Snapshot is missing service info table for COSE receipt verification"
1065+
)
1066+
1067+
service_info = service_info_table.get(WELL_KNOWN_SINGLETON_TABLE_KEY)
1068+
if service_info is None:
1069+
raise InvalidSnapshotException(
1070+
"Snapshot is missing service info for COSE receipt verification"
1071+
)
1072+
1073+
service_info_json = json.loads(service_info)
1074+
cert = load_pem_x509_certificate(
1075+
service_info_json["cert"].encode("ascii"), default_backend()
1076+
)
1077+
return cert.public_key()
1078+
1079+
def _verify_cose_snapshot_receipt(
1080+
self, receipt_bytes: bytes, snapshot_digest: bytes
1081+
):
1082+
ccf.cose.verify_receipt(
1083+
receipt_bytes, self._service_public_key(), snapshot_digest
1084+
)
1085+
1086+
def _verify_json_snapshot_receipt(
1087+
self, receipt_bytes: bytes, receipt_pos: int, snapshot_digest: bytes
1088+
):
1089+
try:
1090+
receipt = json.loads(receipt_bytes.decode("utf-8"))
1091+
except json.decoder.JSONDecodeError as e:
1092+
raise InvalidSnapshotException(
1093+
f"Cannot read receipt from snapshot {os.path.basename(self._filename)}: Receipt starts at {receipt_pos} (file is {self._file_size} bytes), and contains {receipt_bytes!r}"
1094+
) from e
1095+
1096+
# Receipts included in snapshots always contain leaf components,
1097+
# including a claims digest and commit evidence, from 2.0.0-rc0 onwards.
1098+
# This verification code deliberately does not support snapshots
1099+
# produced by 2.0.0-dev* releases.
1100+
assert "leaf_components" in receipt
1101+
write_set_digest = bytes.fromhex(receipt["leaf_components"]["write_set_digest"])
1102+
claims_digest = bytes.fromhex(receipt["leaf_components"]["claims_digest"])
1103+
if snapshot_digest != claims_digest:
1104+
raise InvalidSnapshotException(
1105+
f"Snapshot digest ({snapshot_digest.hex()}) does not match receipt claim ({claims_digest.hex()})"
1106+
)
1107+
1108+
commit_evidence_digest = sha256(
1109+
receipt["leaf_components"]["commit_evidence"].encode()
1110+
).digest()
1111+
leaf = (
1112+
sha256(write_set_digest + commit_evidence_digest + claims_digest)
1113+
.digest()
1114+
.hex()
1115+
)
1116+
root = ccf.receipt.root(leaf, receipt["proof"])
1117+
node_cert = load_pem_x509_certificate(
1118+
receipt["cert"].encode(), default_backend()
1119+
)
1120+
ccf.receipt.verify(root, receipt["signature"], node_cert)
1121+
10641122

10651123
class TransactionIterator:
10661124
_positions: list[int]

0 commit comments

Comments
 (0)