Implement generation-bound protocol cryptography
This commit is contained in:
parent
fba5471352
commit
af8669c165
11 changed files with 1882 additions and 9 deletions
183
Cargo.lock
generated
183
Cargo.lock
generated
|
|
@ -74,12 +74,27 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -132,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -178,6 +193,12 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
|
|
@ -221,6 +242,15 @@ version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -239,6 +269,16 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -248,6 +288,33 @@ dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
|
|
@ -283,6 +350,16 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.9.6",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
|
|
@ -314,15 +391,25 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.10.4",
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.12.1",
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"crypto-common",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -342,6 +429,30 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -376,6 +487,12 @@ version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.29"
|
version = "0.2.29"
|
||||||
|
|
@ -483,6 +600,16 @@ dependencies = [
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -921,10 +1048,11 @@ dependencies = [
|
||||||
name = "lazaret-core"
|
name = "lazaret-core"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ed25519-dalek",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rustix",
|
"rustix",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2 0.11.0",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
@ -975,7 +1103,7 @@ dependencies = [
|
||||||
"rustix",
|
"rustix",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.11.0",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -1098,7 +1226,7 @@ dependencies = [
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.11.0",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -1174,6 +1302,16 @@ version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
|
|
@ -1636,6 +1774,17 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -1643,8 +1792,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"digest",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1716,6 +1865,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -2093,6 +2252,12 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ license = "Apache-2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "=1.0.103"
|
anyhow = "=1.0.103"
|
||||||
|
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
||||||
flate2 = "=1.1.9"
|
flate2 = "=1.1.9"
|
||||||
oci-client = { version = "=0.17.0", default-features = false, features = ["rustls-tls"] }
|
oci-client = { version = "=0.17.0", default-features = false, features = ["rustls-tls"] }
|
||||||
oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] }
|
oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ed25519-dalek.workspace = true
|
||||||
postcard.workspace = true
|
postcard.workspace = true
|
||||||
rustix.workspace = true
|
rustix.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod configuration;
|
pub mod configuration;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
pub mod protocol;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
|
|
||||||
pub const PROTOCOL_NAME: &str = "lazaret";
|
pub const PROTOCOL_NAME: &str = "lazaret";
|
||||||
|
|
|
||||||
9
crates/lazaret-core/src/protocol/mod.rs
Normal file
9
crates/lazaret-core/src/protocol/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//! Bounded cryptographic protocol records and generation-scoped validation.
|
||||||
|
|
||||||
|
mod session;
|
||||||
|
mod signing;
|
||||||
|
mod window;
|
||||||
|
|
||||||
|
pub use session::*;
|
||||||
|
pub use signing::*;
|
||||||
|
pub use window::*;
|
||||||
663
crates/lazaret-core/src/protocol/session.rs
Normal file
663
crates/lazaret-core/src/protocol/session.rs
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::identity::{
|
||||||
|
CapabilityId, CertificateDigest, ProtocolCredentialId, RequestDigest, RequestId,
|
||||||
|
TrustManifestDigest,
|
||||||
|
};
|
||||||
|
use crate::records::{
|
||||||
|
DeveloperDecision, GenerationIdentity, MailboxCancellation, MailboxRequest, MailboxResult,
|
||||||
|
MailboxResultAck, MailboxResultChunk, ProtocolCredential, ProtocolCredentialRole,
|
||||||
|
ProtocolSubjectId, ProtocolVersion, SessionBinding, SessionRequestCapability, TlsCredential,
|
||||||
|
TlsRole, TlsSubjectId, TrustManifest,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::signing::{
|
||||||
|
ProtocolCodecError, ProtocolRecordKind, decode_signed, encode_payload, sha256, sign_payload,
|
||||||
|
trust_manifest_digest, verify_payload,
|
||||||
|
};
|
||||||
|
use super::window::MAX_UNACKNOWLEDGED_CHUNKS;
|
||||||
|
use super::{MAX_RESULT_CHUNK_BYTES, request_digest};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ProtocolValidationError {
|
||||||
|
Codec(ProtocolCodecError),
|
||||||
|
InvalidPublicKey,
|
||||||
|
ManifestDigestMismatch,
|
||||||
|
CertificateDigestMismatch,
|
||||||
|
CredentialDigestMismatch,
|
||||||
|
CapabilityDigestMismatch,
|
||||||
|
GenerationMismatch,
|
||||||
|
RoleMismatch,
|
||||||
|
SubjectMismatch,
|
||||||
|
SessionBindingMismatch,
|
||||||
|
CredentialMismatch,
|
||||||
|
CapabilityMismatch,
|
||||||
|
RequestMismatch,
|
||||||
|
RequestReplay,
|
||||||
|
ChunkTooLarge { length: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProtocolValidationError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Codec(error) => error.fmt(formatter),
|
||||||
|
Self::InvalidPublicKey => formatter.write_str("protocol public key is invalid"),
|
||||||
|
Self::ManifestDigestMismatch => formatter.write_str("Trust Manifest digest mismatch"),
|
||||||
|
Self::CertificateDigestMismatch => {
|
||||||
|
formatter.write_str("TLS certificate digest mismatch")
|
||||||
|
}
|
||||||
|
Self::CredentialDigestMismatch => {
|
||||||
|
formatter.write_str("protocol credential digest mismatch")
|
||||||
|
}
|
||||||
|
Self::CapabilityDigestMismatch => {
|
||||||
|
formatter.write_str("Session Request Capability digest mismatch")
|
||||||
|
}
|
||||||
|
Self::GenerationMismatch => {
|
||||||
|
formatter.write_str("protocol generation identity mismatch")
|
||||||
|
}
|
||||||
|
Self::RoleMismatch => formatter.write_str("protocol credential role mismatch"),
|
||||||
|
Self::SubjectMismatch => formatter.write_str("protocol subject identity mismatch"),
|
||||||
|
Self::SessionBindingMismatch => formatter.write_str("session binding mismatch"),
|
||||||
|
Self::CredentialMismatch => formatter.write_str("protocol credential mismatch"),
|
||||||
|
Self::CapabilityMismatch => formatter.write_str("Session Request Capability mismatch"),
|
||||||
|
Self::RequestMismatch => formatter.write_str("escalation request correlation mismatch"),
|
||||||
|
Self::RequestReplay => {
|
||||||
|
formatter.write_str("escalation request ID was already admitted")
|
||||||
|
}
|
||||||
|
Self::ChunkTooLarge { length } => write!(
|
||||||
|
formatter,
|
||||||
|
"result chunk length {length} exceeds {MAX_RESULT_CHUNK_BYTES}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ProtocolValidationError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Codec(error) => Some(error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProtocolCodecError> for ProtocolValidationError {
|
||||||
|
fn from(error: ProtocolCodecError) -> Self {
|
||||||
|
Self::Codec(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TrustManifestClaims<'a> {
|
||||||
|
protocol_version: ProtocolVersion,
|
||||||
|
generation_identity: &'a GenerationIdentity,
|
||||||
|
tls_ca_sha256: CertificateDigest,
|
||||||
|
protocol_issuer_public_key: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ProtocolCredentialClaims<'a> {
|
||||||
|
protocol_version: ProtocolVersion,
|
||||||
|
generation_identity: &'a GenerationIdentity,
|
||||||
|
role: ProtocolCredentialRole,
|
||||||
|
subject_id: ProtocolSubjectId,
|
||||||
|
public_key: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionCapabilityClaims<'a> {
|
||||||
|
protocol_version: ProtocolVersion,
|
||||||
|
session_binding: &'a SessionBinding,
|
||||||
|
coordinator_credential_id: ProtocolCredentialId,
|
||||||
|
sandbox_credential_id: ProtocolCredentialId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a self-verifying manifest whose digest must still arrive through a trusted path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when a claim cannot be encoded.
|
||||||
|
pub fn issue_trust_manifest(
|
||||||
|
generation_identity: GenerationIdentity,
|
||||||
|
tls_ca_sha256: CertificateDigest,
|
||||||
|
protocol_issuer: &SigningKey,
|
||||||
|
) -> Result<(TrustManifest, TrustManifestDigest), ProtocolValidationError> {
|
||||||
|
let public_key = protocol_issuer.verifying_key().to_bytes().to_vec();
|
||||||
|
let claims = TrustManifestClaims {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
generation_identity: &generation_identity,
|
||||||
|
tls_ca_sha256,
|
||||||
|
protocol_issuer_public_key: &public_key,
|
||||||
|
};
|
||||||
|
let claims = encode_payload(&claims)?;
|
||||||
|
let signature = sign_payload(ProtocolRecordKind::TrustManifest, &claims, protocol_issuer)?;
|
||||||
|
let manifest = TrustManifest {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
generation_identity,
|
||||||
|
tls_ca_sha256,
|
||||||
|
protocol_issuer_public_key: public_key,
|
||||||
|
issuer_signature: signature.to_vec(),
|
||||||
|
};
|
||||||
|
let encoded = encode_payload(&manifest)?;
|
||||||
|
let digest = trust_manifest_digest(&encoded);
|
||||||
|
Ok((manifest, digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a pinned Trust Manifest and returns its protocol issuer key.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for a stale tuple, digest mismatch, malformed key, or invalid signature.
|
||||||
|
pub fn verify_trust_manifest(
|
||||||
|
manifest: &TrustManifest,
|
||||||
|
trusted_digest: TrustManifestDigest,
|
||||||
|
expected_generation: &GenerationIdentity,
|
||||||
|
expected_tls_ca_sha256: CertificateDigest,
|
||||||
|
) -> Result<VerifyingKey, ProtocolValidationError> {
|
||||||
|
if &manifest.generation_identity != expected_generation {
|
||||||
|
return Err(ProtocolValidationError::GenerationMismatch);
|
||||||
|
}
|
||||||
|
if manifest.tls_ca_sha256 != expected_tls_ca_sha256 {
|
||||||
|
return Err(ProtocolValidationError::CertificateDigestMismatch);
|
||||||
|
}
|
||||||
|
let encoded = encode_payload(manifest)?;
|
||||||
|
if trust_manifest_digest(&encoded) != trusted_digest {
|
||||||
|
return Err(ProtocolValidationError::ManifestDigestMismatch);
|
||||||
|
}
|
||||||
|
let key_bytes: [u8; 32] = manifest
|
||||||
|
.protocol_issuer_public_key
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
|
||||||
|
let key = VerifyingKey::from_bytes(&key_bytes)
|
||||||
|
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
|
||||||
|
let claims = TrustManifestClaims {
|
||||||
|
protocol_version: manifest.protocol_version,
|
||||||
|
generation_identity: &manifest.generation_identity,
|
||||||
|
tls_ca_sha256: manifest.tls_ca_sha256,
|
||||||
|
protocol_issuer_public_key: &manifest.protocol_issuer_public_key,
|
||||||
|
};
|
||||||
|
verify_payload(
|
||||||
|
ProtocolRecordKind::TrustManifest,
|
||||||
|
&encode_payload(&claims)?,
|
||||||
|
&manifest.issuer_signature,
|
||||||
|
&key,
|
||||||
|
)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues one generation- and role-bound protocol credential.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when a claim cannot be encoded.
|
||||||
|
pub fn issue_protocol_credential(
|
||||||
|
generation_identity: GenerationIdentity,
|
||||||
|
role: ProtocolCredentialRole,
|
||||||
|
subject_id: ProtocolSubjectId,
|
||||||
|
subject_key: &VerifyingKey,
|
||||||
|
protocol_issuer: &SigningKey,
|
||||||
|
) -> Result<ProtocolCredential, ProtocolValidationError> {
|
||||||
|
let public_key = subject_key.to_bytes().to_vec();
|
||||||
|
let claims = ProtocolCredentialClaims {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
generation_identity: &generation_identity,
|
||||||
|
role,
|
||||||
|
subject_id,
|
||||||
|
public_key: &public_key,
|
||||||
|
};
|
||||||
|
let claims = encode_payload(&claims)?;
|
||||||
|
let credential_id = ProtocolCredentialId::new(sha256(&claims));
|
||||||
|
let signature = sign_payload(
|
||||||
|
ProtocolRecordKind::ProtocolCredential,
|
||||||
|
&claims,
|
||||||
|
protocol_issuer,
|
||||||
|
)?;
|
||||||
|
let credential = match (role, subject_id) {
|
||||||
|
(
|
||||||
|
ProtocolCredentialRole::SessionCoordinator,
|
||||||
|
ProtocolSubjectId::SessionCoordinator(subject_id),
|
||||||
|
) => ProtocolCredential::session_coordinator(
|
||||||
|
credential_id,
|
||||||
|
generation_identity,
|
||||||
|
subject_id,
|
||||||
|
public_key,
|
||||||
|
signature.to_vec(),
|
||||||
|
),
|
||||||
|
(ProtocolCredentialRole::AgentSandbox, ProtocolSubjectId::AgentSandbox(subject_id)) => {
|
||||||
|
ProtocolCredential::agent_sandbox(
|
||||||
|
credential_id,
|
||||||
|
generation_identity,
|
||||||
|
subject_id,
|
||||||
|
public_key,
|
||||||
|
signature.to_vec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return Err(ProtocolValidationError::RoleMismatch),
|
||||||
|
};
|
||||||
|
Ok(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates one protocol credential against its issuer and exact authority tuple.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for key, role, subject, generation, digest, or signature mismatch.
|
||||||
|
pub fn verify_protocol_credential(
|
||||||
|
credential: &ProtocolCredential,
|
||||||
|
protocol_issuer: &VerifyingKey,
|
||||||
|
expected_generation: &GenerationIdentity,
|
||||||
|
expected_role: ProtocolCredentialRole,
|
||||||
|
expected_subject: ProtocolSubjectId,
|
||||||
|
) -> Result<VerifyingKey, ProtocolValidationError> {
|
||||||
|
if &credential.generation_identity != expected_generation {
|
||||||
|
return Err(ProtocolValidationError::GenerationMismatch);
|
||||||
|
}
|
||||||
|
if credential.role() != expected_role {
|
||||||
|
return Err(ProtocolValidationError::RoleMismatch);
|
||||||
|
}
|
||||||
|
if credential.subject_id() != expected_subject {
|
||||||
|
return Err(ProtocolValidationError::SubjectMismatch);
|
||||||
|
}
|
||||||
|
let claims = ProtocolCredentialClaims {
|
||||||
|
protocol_version: credential.protocol_version,
|
||||||
|
generation_identity: &credential.generation_identity,
|
||||||
|
role: credential.role(),
|
||||||
|
subject_id: credential.subject_id(),
|
||||||
|
public_key: &credential.public_key,
|
||||||
|
};
|
||||||
|
let claims = encode_payload(&claims)?;
|
||||||
|
if credential.credential_id != ProtocolCredentialId::new(sha256(&claims)) {
|
||||||
|
return Err(ProtocolValidationError::CredentialDigestMismatch);
|
||||||
|
}
|
||||||
|
verify_payload(
|
||||||
|
ProtocolRecordKind::ProtocolCredential,
|
||||||
|
&claims,
|
||||||
|
&credential.issuer_signature,
|
||||||
|
protocol_issuer,
|
||||||
|
)?;
|
||||||
|
let key_bytes: [u8; 32] = credential
|
||||||
|
.public_key
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
|
||||||
|
VerifyingKey::from_bytes(&key_bytes).map_err(|_| ProtocolValidationError::InvalidPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues one capability for an exact live session and credential pair.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when its claims cannot be encoded.
|
||||||
|
pub fn issue_session_capability(
|
||||||
|
session_binding: SessionBinding,
|
||||||
|
coordinator_credential_id: ProtocolCredentialId,
|
||||||
|
sandbox_credential_id: ProtocolCredentialId,
|
||||||
|
coordinator_key: &SigningKey,
|
||||||
|
) -> Result<SessionRequestCapability, ProtocolValidationError> {
|
||||||
|
let claims = SessionCapabilityClaims {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: &session_binding,
|
||||||
|
coordinator_credential_id,
|
||||||
|
sandbox_credential_id,
|
||||||
|
};
|
||||||
|
let claims = encode_payload(&claims)?;
|
||||||
|
let capability_id = CapabilityId::new(sha256(&claims));
|
||||||
|
let signature = sign_payload(
|
||||||
|
ProtocolRecordKind::SessionRequestCapability,
|
||||||
|
&claims,
|
||||||
|
coordinator_key,
|
||||||
|
)?;
|
||||||
|
Ok(SessionRequestCapability {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
capability_id,
|
||||||
|
session_binding,
|
||||||
|
coordinator_credential_id,
|
||||||
|
sandbox_credential_id,
|
||||||
|
coordinator_signature: signature.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_session_capability(
|
||||||
|
capability: &SessionRequestCapability,
|
||||||
|
session_binding: &SessionBinding,
|
||||||
|
coordinator_credential_id: ProtocolCredentialId,
|
||||||
|
sandbox_credential_id: ProtocolCredentialId,
|
||||||
|
coordinator_key: &VerifyingKey,
|
||||||
|
) -> Result<(), ProtocolValidationError> {
|
||||||
|
if &capability.session_binding != session_binding {
|
||||||
|
return Err(ProtocolValidationError::SessionBindingMismatch);
|
||||||
|
}
|
||||||
|
if capability.coordinator_credential_id != coordinator_credential_id
|
||||||
|
|| capability.sandbox_credential_id != sandbox_credential_id
|
||||||
|
{
|
||||||
|
return Err(ProtocolValidationError::CredentialMismatch);
|
||||||
|
}
|
||||||
|
let claims = SessionCapabilityClaims {
|
||||||
|
protocol_version: capability.protocol_version,
|
||||||
|
session_binding: &capability.session_binding,
|
||||||
|
coordinator_credential_id: capability.coordinator_credential_id,
|
||||||
|
sandbox_credential_id: capability.sandbox_credential_id,
|
||||||
|
};
|
||||||
|
let claims = encode_payload(&claims)?;
|
||||||
|
if capability.capability_id != CapabilityId::new(sha256(&claims)) {
|
||||||
|
return Err(ProtocolValidationError::CapabilityDigestMismatch);
|
||||||
|
}
|
||||||
|
verify_payload(
|
||||||
|
ProtocolRecordKind::SessionRequestCapability,
|
||||||
|
&claims,
|
||||||
|
&capability.coordinator_signature,
|
||||||
|
coordinator_key,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the certificate bytes and exact TLS identity tuple carried by a TLS credential.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for a stale tuple, role/subject mismatch, or certificate digest mismatch.
|
||||||
|
pub fn verify_tls_credential(
|
||||||
|
credential: &TlsCredential,
|
||||||
|
expected_generation: &GenerationIdentity,
|
||||||
|
expected_role: TlsRole,
|
||||||
|
expected_subject: TlsSubjectId,
|
||||||
|
) -> Result<(), ProtocolValidationError> {
|
||||||
|
if &credential.generation_identity != expected_generation {
|
||||||
|
return Err(ProtocolValidationError::GenerationMismatch);
|
||||||
|
}
|
||||||
|
if credential.role() != expected_role {
|
||||||
|
return Err(ProtocolValidationError::RoleMismatch);
|
||||||
|
}
|
||||||
|
if credential.subject_id() != expected_subject {
|
||||||
|
return Err(ProtocolValidationError::SubjectMismatch);
|
||||||
|
}
|
||||||
|
if credential.certificate_sha256 != CertificateDigest::new(sha256(&credential.certificate_der))
|
||||||
|
{
|
||||||
|
return Err(ProtocolValidationError::CertificateDigestMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VerifiedSession {
|
||||||
|
session_binding: SessionBinding,
|
||||||
|
capability: SessionRequestCapability,
|
||||||
|
sandbox_credential_id: ProtocolCredentialId,
|
||||||
|
coordinator_credential_id: ProtocolCredentialId,
|
||||||
|
sandbox_key: VerifyingKey,
|
||||||
|
coordinator_key: VerifyingKey,
|
||||||
|
session_anchor_key: VerifyingKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VerifiedSession {
|
||||||
|
/// Constructs an authority only after validating its manifest, credentials, and capability.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when any key or identity tuple fails validation.
|
||||||
|
pub fn new(
|
||||||
|
trusted_manifest_digest: TrustManifestDigest,
|
||||||
|
tls_ca_sha256: CertificateDigest,
|
||||||
|
manifest: &TrustManifest,
|
||||||
|
session_binding: SessionBinding,
|
||||||
|
coordinator_credential: &ProtocolCredential,
|
||||||
|
sandbox_credential: &ProtocolCredential,
|
||||||
|
capability: SessionRequestCapability,
|
||||||
|
) -> Result<Self, ProtocolValidationError> {
|
||||||
|
let generation = &session_binding.generation_identity;
|
||||||
|
let issuer =
|
||||||
|
verify_trust_manifest(manifest, trusted_manifest_digest, generation, tls_ca_sha256)?;
|
||||||
|
let coordinator_key = verify_protocol_credential(
|
||||||
|
coordinator_credential,
|
||||||
|
&issuer,
|
||||||
|
generation,
|
||||||
|
ProtocolCredentialRole::SessionCoordinator,
|
||||||
|
ProtocolSubjectId::SessionCoordinator(generation.coordinator_id),
|
||||||
|
)?;
|
||||||
|
let sandbox_key = verify_protocol_credential(
|
||||||
|
sandbox_credential,
|
||||||
|
&issuer,
|
||||||
|
generation,
|
||||||
|
ProtocolCredentialRole::AgentSandbox,
|
||||||
|
ProtocolSubjectId::AgentSandbox(generation.sandbox_id),
|
||||||
|
)?;
|
||||||
|
verify_session_capability(
|
||||||
|
&capability,
|
||||||
|
&session_binding,
|
||||||
|
coordinator_credential.credential_id,
|
||||||
|
sandbox_credential.credential_id,
|
||||||
|
&coordinator_key,
|
||||||
|
)?;
|
||||||
|
let anchor_bytes: [u8; 32] = session_binding
|
||||||
|
.session_anchor_public_key
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
|
||||||
|
let session_anchor_key = VerifyingKey::from_bytes(&anchor_bytes)
|
||||||
|
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
|
||||||
|
Ok(Self {
|
||||||
|
session_binding,
|
||||||
|
capability,
|
||||||
|
sandbox_credential_id: sandbox_credential.credential_id,
|
||||||
|
coordinator_credential_id: coordinator_credential.credential_id,
|
||||||
|
sandbox_key,
|
||||||
|
coordinator_key,
|
||||||
|
session_anchor_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_sandbox_tuple(
|
||||||
|
&self,
|
||||||
|
session_binding: &SessionBinding,
|
||||||
|
capability: &SessionRequestCapability,
|
||||||
|
credential_id: ProtocolCredentialId,
|
||||||
|
) -> Result<(), ProtocolValidationError> {
|
||||||
|
if session_binding != &self.session_binding {
|
||||||
|
return Err(ProtocolValidationError::SessionBindingMismatch);
|
||||||
|
}
|
||||||
|
if capability != &self.capability {
|
||||||
|
return Err(ProtocolValidationError::CapabilityMismatch);
|
||||||
|
}
|
||||||
|
if credential_id != self.sandbox_credential_id {
|
||||||
|
return Err(ProtocolValidationError::CredentialMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies an exact signed request and returns its digest over accepted payload bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, or tuple mismatch.
|
||||||
|
pub fn verify_request(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
) -> Result<(MailboxRequest, RequestDigest), ProtocolValidationError> {
|
||||||
|
let (request, payload_bytes) = decode_signed::<MailboxRequest>(encoded, &self.sandbox_key)?;
|
||||||
|
self.verify_sandbox_tuple(
|
||||||
|
request.session_binding(),
|
||||||
|
request.session_capability(),
|
||||||
|
request.sandbox_credential_id(),
|
||||||
|
)?;
|
||||||
|
Ok((request, request_digest(&payload_bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a signed cancellation for the exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, tuple, or request mismatch.
|
||||||
|
pub fn verify_cancellation(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
request_id: RequestId,
|
||||||
|
) -> Result<MailboxCancellation, ProtocolValidationError> {
|
||||||
|
let (cancellation, _) = decode_signed::<MailboxCancellation>(encoded, &self.sandbox_key)?;
|
||||||
|
self.verify_sandbox_tuple(
|
||||||
|
&cancellation.session_binding,
|
||||||
|
&cancellation.session_capability,
|
||||||
|
cancellation.sandbox_credential_id,
|
||||||
|
)?;
|
||||||
|
if cancellation.request_id != request_id {
|
||||||
|
return Err(ProtocolValidationError::RequestMismatch);
|
||||||
|
}
|
||||||
|
Ok(cancellation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a signed highest-contiguous acknowledgement for the exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, tuple, or request mismatch.
|
||||||
|
pub fn verify_result_ack(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
request_id: RequestId,
|
||||||
|
digest: RequestDigest,
|
||||||
|
) -> Result<MailboxResultAck, ProtocolValidationError> {
|
||||||
|
let (ack, _) = decode_signed::<MailboxResultAck>(encoded, &self.sandbox_key)?;
|
||||||
|
self.verify_sandbox_tuple(
|
||||||
|
&ack.session_binding,
|
||||||
|
&ack.session_capability,
|
||||||
|
ack.sandbox_credential_id,
|
||||||
|
)?;
|
||||||
|
if ack.request_id != request_id || ack.request_sha256 != digest {
|
||||||
|
return Err(ProtocolValidationError::RequestMismatch);
|
||||||
|
}
|
||||||
|
Ok(ack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies one host-only Session Anchor decision for the exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, tuple, or request mismatch.
|
||||||
|
pub fn verify_developer_decision(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
request_id: RequestId,
|
||||||
|
digest: RequestDigest,
|
||||||
|
) -> Result<DeveloperDecision, ProtocolValidationError> {
|
||||||
|
let (decision, _) = decode_signed::<DeveloperDecision>(encoded, &self.session_anchor_key)?;
|
||||||
|
if decision.session_binding != self.session_binding {
|
||||||
|
return Err(ProtocolValidationError::SessionBindingMismatch);
|
||||||
|
}
|
||||||
|
if decision.request_id != request_id || decision.request_sha256 != digest {
|
||||||
|
return Err(ProtocolValidationError::RequestMismatch);
|
||||||
|
}
|
||||||
|
Ok(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies one coordinator result chunk for the exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, tuple, request, or size mismatch.
|
||||||
|
pub fn verify_result_chunk(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
request_id: RequestId,
|
||||||
|
digest: RequestDigest,
|
||||||
|
) -> Result<MailboxResultChunk, ProtocolValidationError> {
|
||||||
|
let (chunk, _) = decode_signed::<MailboxResultChunk>(encoded, &self.coordinator_key)?;
|
||||||
|
if chunk.bytes.len() > MAX_RESULT_CHUNK_BYTES {
|
||||||
|
return Err(ProtocolValidationError::ChunkTooLarge {
|
||||||
|
length: chunk.bytes.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.verify_coordinator_tuple(
|
||||||
|
&chunk.session_binding,
|
||||||
|
chunk.request_id,
|
||||||
|
chunk.request_sha256,
|
||||||
|
chunk.coordinator_credential_id,
|
||||||
|
request_id,
|
||||||
|
digest,
|
||||||
|
)?;
|
||||||
|
Ok(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies one coordinator terminal result for the exact request.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for signature, encoding, tuple, or request mismatch.
|
||||||
|
pub fn verify_result(
|
||||||
|
&self,
|
||||||
|
encoded: &[u8],
|
||||||
|
request_id: RequestId,
|
||||||
|
digest: RequestDigest,
|
||||||
|
) -> Result<MailboxResult, ProtocolValidationError> {
|
||||||
|
let (result, _) = decode_signed::<MailboxResult>(encoded, &self.coordinator_key)?;
|
||||||
|
self.verify_coordinator_tuple(
|
||||||
|
&result.session_binding,
|
||||||
|
result.request_id,
|
||||||
|
result.request_sha256,
|
||||||
|
result.coordinator_credential_id,
|
||||||
|
request_id,
|
||||||
|
digest,
|
||||||
|
)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_coordinator_tuple(
|
||||||
|
&self,
|
||||||
|
session_binding: &SessionBinding,
|
||||||
|
actual_request_id: RequestId,
|
||||||
|
actual_digest: RequestDigest,
|
||||||
|
credential_id: ProtocolCredentialId,
|
||||||
|
expected_request_id: RequestId,
|
||||||
|
expected_digest: RequestDigest,
|
||||||
|
) -> Result<(), ProtocolValidationError> {
|
||||||
|
if session_binding != &self.session_binding {
|
||||||
|
return Err(ProtocolValidationError::SessionBindingMismatch);
|
||||||
|
}
|
||||||
|
if credential_id != self.coordinator_credential_id {
|
||||||
|
return Err(ProtocolValidationError::CredentialMismatch);
|
||||||
|
}
|
||||||
|
if actual_request_id != expected_request_id || actual_digest != expected_digest {
|
||||||
|
return Err(ProtocolValidationError::RequestMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct RequestReplayGuard {
|
||||||
|
admitted: BTreeSet<RequestId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestReplayGuard {
|
||||||
|
/// Verifies and records a request ID exactly once for this generation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when validation fails or the request ID was previously admitted.
|
||||||
|
pub fn admit(
|
||||||
|
&mut self,
|
||||||
|
session: &VerifiedSession,
|
||||||
|
encoded: &[u8],
|
||||||
|
) -> Result<(MailboxRequest, RequestDigest), ProtocolValidationError> {
|
||||||
|
let (request, digest) = session.verify_request(encoded)?;
|
||||||
|
if !self.admitted.insert(request.request_id()) {
|
||||||
|
return Err(ProtocolValidationError::RequestReplay);
|
||||||
|
}
|
||||||
|
Ok((request, digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.admitted.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.admitted.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: () = assert!(MAX_UNACKNOWLEDGED_CHUNKS == 8);
|
||||||
339
crates/lazaret-core/src/protocol/signing.rs
Normal file
339
crates/lazaret-core/src/protocol/signing.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::identity::{RequestDigest, StreamDigest, TrustManifestDigest};
|
||||||
|
use crate::records::{
|
||||||
|
DeveloperDecision, MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck,
|
||||||
|
MailboxResultChunk, SignedProtocolRecord,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_SIGNED_RECORD_BYTES: usize = 65_536;
|
||||||
|
pub const MAX_RESULT_CHUNK_BYTES: usize = 49_152;
|
||||||
|
const SIGNATURE_BYTES: usize = 64;
|
||||||
|
const LENGTH_BYTES: usize = size_of::<u64>();
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ProtocolRecordKind {
|
||||||
|
TrustManifest,
|
||||||
|
ProtocolCredential,
|
||||||
|
SessionRequestCapability,
|
||||||
|
MailboxRequest,
|
||||||
|
MailboxCancellation,
|
||||||
|
MailboxResultAck,
|
||||||
|
DeveloperDecision,
|
||||||
|
MailboxResult,
|
||||||
|
MailboxResultChunk,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolRecordKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn signing_context(self) -> &'static [u8] {
|
||||||
|
match self {
|
||||||
|
Self::TrustManifest => b"lazaret/v1/trust-manifest",
|
||||||
|
Self::ProtocolCredential => b"lazaret/v1/protocol-credential",
|
||||||
|
Self::SessionRequestCapability => b"lazaret/v1/session-request-capability",
|
||||||
|
Self::MailboxRequest => b"lazaret/v1/mailbox-request",
|
||||||
|
Self::MailboxCancellation => b"lazaret/v1/mailbox-cancellation",
|
||||||
|
Self::MailboxResultAck => b"lazaret/v1/mailbox-result-ack",
|
||||||
|
Self::DeveloperDecision => b"lazaret/v1/developer-decision",
|
||||||
|
Self::MailboxResult => b"lazaret/v1/mailbox-result",
|
||||||
|
Self::MailboxResultChunk => b"lazaret/v1/mailbox-result-chunk",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProtocolPayload: DeserializeOwned + Serialize {
|
||||||
|
const RECORD_KIND: ProtocolRecordKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! protocol_payload {
|
||||||
|
($($type:ty => $kind:ident),+ $(,)?) => {
|
||||||
|
$(
|
||||||
|
impl ProtocolPayload for $type {
|
||||||
|
const RECORD_KIND: ProtocolRecordKind = ProtocolRecordKind::$kind;
|
||||||
|
}
|
||||||
|
)+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol_payload!(
|
||||||
|
MailboxRequest => MailboxRequest,
|
||||||
|
MailboxCancellation => MailboxCancellation,
|
||||||
|
MailboxResultAck => MailboxResultAck,
|
||||||
|
DeveloperDecision => DeveloperDecision,
|
||||||
|
MailboxResult => MailboxResult,
|
||||||
|
MailboxResultChunk => MailboxResultChunk,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ProtocolCodecError {
|
||||||
|
RecordTooLarge { length: usize },
|
||||||
|
LengthOverflow,
|
||||||
|
Malformed(postcard::Error),
|
||||||
|
TrailingBytes,
|
||||||
|
NonMinimalEncoding,
|
||||||
|
InvalidSignatureLength { length: usize },
|
||||||
|
InvalidSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProtocolCodecError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::RecordTooLarge { length } => write!(
|
||||||
|
formatter,
|
||||||
|
"signed protocol record length {length} exceeds {MAX_SIGNED_RECORD_BYTES}"
|
||||||
|
),
|
||||||
|
Self::LengthOverflow => formatter.write_str("protocol payload length exceeds u64"),
|
||||||
|
Self::Malformed(error) => write!(formatter, "malformed protocol record: {error}"),
|
||||||
|
Self::TrailingBytes => formatter.write_str("protocol record has trailing bytes"),
|
||||||
|
Self::NonMinimalEncoding => {
|
||||||
|
formatter.write_str("protocol record does not use minimal Postcard encoding")
|
||||||
|
}
|
||||||
|
Self::InvalidSignatureLength { length } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"protocol signature length is {length}, expected 64"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidSignature => formatter.write_str("protocol signature is invalid"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ProtocolCodecError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Malformed(error) => Some(error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode_payload<T: Serialize>(payload: &T) -> Result<Vec<u8>, ProtocolCodecError> {
|
||||||
|
postcard::to_stdvec(payload).map_err(ProtocolCodecError::Malformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_payload<T>(bytes: &[u8]) -> Result<T, ProtocolCodecError>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Serialize,
|
||||||
|
{
|
||||||
|
let (value, remainder) =
|
||||||
|
postcard::take_from_bytes(bytes).map_err(ProtocolCodecError::Malformed)?;
|
||||||
|
if !remainder.is_empty() {
|
||||||
|
return Err(ProtocolCodecError::TrailingBytes);
|
||||||
|
}
|
||||||
|
if encode_payload(&value)? != bytes {
|
||||||
|
return Err(ProtocolCodecError::NonMinimalEncoding);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_message(
|
||||||
|
kind: ProtocolRecordKind,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Result<Vec<u8>, ProtocolCodecError> {
|
||||||
|
let length = u64::try_from(payload.len()).map_err(|_| ProtocolCodecError::LengthOverflow)?;
|
||||||
|
let context = kind.signing_context();
|
||||||
|
let mut message = Vec::with_capacity(context.len() + LENGTH_BYTES + payload.len());
|
||||||
|
message.extend_from_slice(context);
|
||||||
|
message.extend_from_slice(&length.to_be_bytes());
|
||||||
|
message.extend_from_slice(payload);
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sign_payload(
|
||||||
|
kind: ProtocolRecordKind,
|
||||||
|
payload: &[u8],
|
||||||
|
signing_key: &SigningKey,
|
||||||
|
) -> Result<[u8; SIGNATURE_BYTES], ProtocolCodecError> {
|
||||||
|
Ok(signing_key
|
||||||
|
.sign(&signing_message(kind, payload)?)
|
||||||
|
.to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_payload(
|
||||||
|
kind: ProtocolRecordKind,
|
||||||
|
payload: &[u8],
|
||||||
|
signature: &[u8],
|
||||||
|
verifying_key: &VerifyingKey,
|
||||||
|
) -> Result<(), ProtocolCodecError> {
|
||||||
|
let signature = Signature::from_slice(signature).map_err(|_| {
|
||||||
|
ProtocolCodecError::InvalidSignatureLength {
|
||||||
|
length: signature.len(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
verifying_key
|
||||||
|
.verify_strict(&signing_message(kind, payload)?, &signature)
|
||||||
|
.map_err(|_| ProtocolCodecError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs the minimal Postcard payload and returns one bounded outer record.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when serialization fails or the complete record exceeds 65,536 bytes.
|
||||||
|
pub fn encode_signed<T: ProtocolPayload>(
|
||||||
|
payload: &T,
|
||||||
|
signing_key: &SigningKey,
|
||||||
|
) -> Result<Vec<u8>, ProtocolCodecError> {
|
||||||
|
let payload_bytes = encode_payload(payload)?;
|
||||||
|
let signature = sign_payload(T::RECORD_KIND, &payload_bytes, signing_key)?;
|
||||||
|
let record = SignedProtocolRecord {
|
||||||
|
payload_bytes,
|
||||||
|
signature: signature.to_vec(),
|
||||||
|
};
|
||||||
|
let encoded = encode_payload(&record)?;
|
||||||
|
if encoded.len() > MAX_SIGNED_RECORD_BYTES {
|
||||||
|
return Err(ProtocolCodecError::RecordTooLarge {
|
||||||
|
length: encoded.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_signed<T>(
|
||||||
|
encoded: &[u8],
|
||||||
|
verifying_key: &VerifyingKey,
|
||||||
|
) -> Result<(T, Vec<u8>), ProtocolCodecError>
|
||||||
|
where
|
||||||
|
T: ProtocolPayload,
|
||||||
|
{
|
||||||
|
if encoded.len() > MAX_SIGNED_RECORD_BYTES {
|
||||||
|
return Err(ProtocolCodecError::RecordTooLarge {
|
||||||
|
length: encoded.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let record: SignedProtocolRecord = decode_payload(encoded)?;
|
||||||
|
verify_payload(
|
||||||
|
T::RECORD_KIND,
|
||||||
|
&record.payload_bytes,
|
||||||
|
&record.signature,
|
||||||
|
verifying_key,
|
||||||
|
)?;
|
||||||
|
let payload = decode_payload(&record.payload_bytes)?;
|
||||||
|
Ok((payload, record.payload_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(bytes).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_digest(payload_bytes: &[u8]) -> RequestDigest {
|
||||||
|
RequestDigest::new(sha256(payload_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn stream_digest(bytes: &[u8]) -> StreamDigest {
|
||||||
|
StreamDigest::new(sha256(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn trust_manifest_digest(encoded_manifest: &[u8]) -> TrustManifestDigest {
|
||||||
|
TrustManifestDigest::new(sha256(encoded_manifest))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::identity::{
|
||||||
|
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId,
|
||||||
|
ProtocolCredentialId, RequestDigest, RequestId, SandboxId, SessionId,
|
||||||
|
};
|
||||||
|
use crate::records::{
|
||||||
|
GenerationIdentity, MailboxResultChunk, ResultStream, SessionBinding, SignedProtocolRecord,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ProtocolCodecError, ProtocolPayload, ProtocolRecordKind, decode_signed, encode_payload,
|
||||||
|
sign_payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_context_and_length_have_an_exact_vector() {
|
||||||
|
let key = SigningKey::from_bytes(&[7; 32]);
|
||||||
|
let signature = sign_payload(ProtocolRecordKind::MailboxRequest, &[0], &key).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
signature,
|
||||||
|
[
|
||||||
|
79, 132, 54, 199, 95, 126, 134, 255, 188, 61, 184, 49, 116, 41, 8, 91, 38, 77, 75,
|
||||||
|
188, 184, 7, 164, 133, 194, 37, 215, 228, 53, 141, 99, 203, 254, 220, 210, 170,
|
||||||
|
157, 52, 147, 199, 116, 247, 230, 77, 128, 126, 113, 16, 115, 207, 134, 21, 128,
|
||||||
|
210, 193, 102, 45, 148, 252, 17, 87, 57, 218, 11,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let record = encode_payload(&SignedProtocolRecord {
|
||||||
|
payload_bytes: vec![0],
|
||||||
|
signature: signature.to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
record,
|
||||||
|
[
|
||||||
|
1, 0, 64, 79, 132, 54, 199, 95, 126, 134, 255, 188, 61, 184, 49, 116, 41, 8, 91,
|
||||||
|
38, 77, 75, 188, 184, 7, 164, 133, 194, 37, 215, 228, 53, 141, 99, 203, 254, 220,
|
||||||
|
210, 170, 157, 52, 147, 199, 116, 247, 230, 77, 128, 126, 113, 16, 115, 207, 134,
|
||||||
|
21, 128, 210, 193, 102, 45, 148, 252, 17, 87, 57, 218, 11,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_signature_does_not_authorize_an_unknown_payload_version() {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UnknownVersionChunk {
|
||||||
|
protocol_version: u8,
|
||||||
|
session_binding: SessionBinding,
|
||||||
|
request_id: RequestId,
|
||||||
|
request_sha256: RequestDigest,
|
||||||
|
coordinator_credential_id: ProtocolCredentialId,
|
||||||
|
sequence: u64,
|
||||||
|
stream: ResultStream,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let opaque = [1; 32];
|
||||||
|
let generation_identity = GenerationIdentity {
|
||||||
|
agent_profile: "agent".into(),
|
||||||
|
domain_id: DomainId::new(opaque),
|
||||||
|
generation_id: GenerationId::new(1),
|
||||||
|
configuration_fingerprint: ConfigurationFingerprint::new(opaque),
|
||||||
|
coordinator_id: CoordinatorId::new(opaque),
|
||||||
|
sandbox_id: SandboxId::new(opaque),
|
||||||
|
};
|
||||||
|
let payload = encode_payload(&UnknownVersionChunk {
|
||||||
|
protocol_version: 1,
|
||||||
|
session_binding: SessionBinding {
|
||||||
|
generation_identity,
|
||||||
|
cli_id: ClientId::new(opaque),
|
||||||
|
session_id: SessionId::new(opaque),
|
||||||
|
session_anchor_public_key: vec![1; 32],
|
||||||
|
},
|
||||||
|
request_id: RequestId::new(opaque),
|
||||||
|
request_sha256: RequestDigest::new(opaque),
|
||||||
|
coordinator_credential_id: ProtocolCredentialId::new(opaque),
|
||||||
|
sequence: 0,
|
||||||
|
stream: ResultStream::Stdout,
|
||||||
|
bytes: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let key = SigningKey::from_bytes(&[8; 32]);
|
||||||
|
let signature = sign_payload(MailboxResultChunk::RECORD_KIND, &payload, &key).unwrap();
|
||||||
|
let encoded = encode_payload(&SignedProtocolRecord {
|
||||||
|
payload_bytes: payload,
|
||||||
|
signature: signature.to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
decode_signed::<MailboxResultChunk>(&encoded, &key.verifying_key()),
|
||||||
|
Err(ProtocolCodecError::Malformed(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
181
crates/lazaret-core/src/protocol/window.rs
Normal file
181
crates/lazaret-core/src/protocol/window.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::identity::StreamDigest;
|
||||||
|
use crate::records::{MailboxResult, MailboxResultChunk, ResultStream};
|
||||||
|
|
||||||
|
use super::MAX_RESULT_CHUNK_BYTES;
|
||||||
|
|
||||||
|
pub const MAX_UNACKNOWLEDGED_CHUNKS: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ResultWindowError {
|
||||||
|
WindowFull,
|
||||||
|
SequenceExhausted,
|
||||||
|
ChunkTooLarge { length: usize },
|
||||||
|
UnexpectedSequence { expected: u64, actual: u64 },
|
||||||
|
AckRegression,
|
||||||
|
AckBeyondSent,
|
||||||
|
ChunkCountMismatch,
|
||||||
|
StdoutDigestMismatch,
|
||||||
|
StderrDigestMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ResultWindowError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::WindowFull => formatter.write_str("result sender window is full"),
|
||||||
|
Self::SequenceExhausted => formatter.write_str("result sequence space is exhausted"),
|
||||||
|
Self::ChunkTooLarge { length } => write!(
|
||||||
|
formatter,
|
||||||
|
"result chunk length {length} exceeds {MAX_RESULT_CHUNK_BYTES}"
|
||||||
|
),
|
||||||
|
Self::UnexpectedSequence { expected, actual } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"expected result sequence {expected}, received {actual}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::AckRegression => {
|
||||||
|
formatter.write_str("result acknowledgement regressed or repeated")
|
||||||
|
}
|
||||||
|
Self::AckBeyondSent => {
|
||||||
|
formatter.write_str("result acknowledgement exceeds sent sequence")
|
||||||
|
}
|
||||||
|
Self::ChunkCountMismatch => formatter.write_str("terminal result chunk count mismatch"),
|
||||||
|
Self::StdoutDigestMismatch => {
|
||||||
|
formatter.write_str("terminal result stdout digest mismatch")
|
||||||
|
}
|
||||||
|
Self::StderrDigestMismatch => {
|
||||||
|
formatter.write_str("terminal result stderr digest mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ResultWindowError {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ResultSenderWindow {
|
||||||
|
sent_count: u64,
|
||||||
|
acknowledged_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResultSenderWindow {
|
||||||
|
/// Reserves the next sequence while enforcing the eight-chunk unacknowledged bound.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the payload is oversized or the sender window is full.
|
||||||
|
pub fn reserve(&mut self, byte_length: usize) -> Result<u64, ResultWindowError> {
|
||||||
|
if byte_length > MAX_RESULT_CHUNK_BYTES {
|
||||||
|
return Err(ResultWindowError::ChunkTooLarge {
|
||||||
|
length: byte_length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.outstanding() >= MAX_UNACKNOWLEDGED_CHUNKS {
|
||||||
|
return Err(ResultWindowError::WindowFull);
|
||||||
|
}
|
||||||
|
let sequence = self.sent_count;
|
||||||
|
self.sent_count = self
|
||||||
|
.sent_count
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ResultWindowError::SequenceExhausted)?;
|
||||||
|
Ok(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the window using a highest-contiguous sequence acknowledgement.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the acknowledgement repeats, regresses, or exceeds sent data.
|
||||||
|
pub fn acknowledge(&mut self, highest_contiguous: u64) -> Result<(), ResultWindowError> {
|
||||||
|
let acknowledged_count = highest_contiguous
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ResultWindowError::AckBeyondSent)?;
|
||||||
|
if acknowledged_count <= self.acknowledged_count {
|
||||||
|
return Err(ResultWindowError::AckRegression);
|
||||||
|
}
|
||||||
|
if acknowledged_count > self.sent_count {
|
||||||
|
return Err(ResultWindowError::AckBeyondSent);
|
||||||
|
}
|
||||||
|
self.acknowledged_count = acknowledged_count;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn outstanding(&self) -> usize {
|
||||||
|
usize::try_from(self.sent_count - self.acknowledged_count).unwrap_or(usize::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ResultCollector {
|
||||||
|
next_sequence: u64,
|
||||||
|
stdout: Sha256,
|
||||||
|
stderr: Sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResultCollector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
next_sequence: 0,
|
||||||
|
stdout: Sha256::new(),
|
||||||
|
stderr: Sha256::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResultCollector {
|
||||||
|
/// Adds the next verified chunk to its ordered stream digest.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for oversized, duplicate, skipped, or reordered chunks.
|
||||||
|
pub fn push(&mut self, chunk: &MailboxResultChunk) -> Result<(), ResultWindowError> {
|
||||||
|
if chunk.bytes.len() > MAX_RESULT_CHUNK_BYTES {
|
||||||
|
return Err(ResultWindowError::ChunkTooLarge {
|
||||||
|
length: chunk.bytes.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if chunk.sequence != self.next_sequence {
|
||||||
|
return Err(ResultWindowError::UnexpectedSequence {
|
||||||
|
expected: self.next_sequence,
|
||||||
|
actual: chunk.sequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match chunk.stream {
|
||||||
|
ResultStream::Stdout => self.stdout.update(&chunk.bytes),
|
||||||
|
ResultStream::Stderr => self.stderr.update(&chunk.bytes),
|
||||||
|
}
|
||||||
|
self.next_sequence = self
|
||||||
|
.next_sequence
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ResultWindowError::SequenceExhausted)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that the terminal record commits the exact ordered chunks received.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the count or either stream digest differs.
|
||||||
|
pub fn finish(&self, result: &MailboxResult) -> Result<(), ResultWindowError> {
|
||||||
|
if result.chunk_count != self.next_sequence {
|
||||||
|
return Err(ResultWindowError::ChunkCountMismatch);
|
||||||
|
}
|
||||||
|
if result.stdout_sha256 != StreamDigest::new(self.stdout.clone().finalize().into()) {
|
||||||
|
return Err(ResultWindowError::StdoutDigestMismatch);
|
||||||
|
}
|
||||||
|
if result.stderr_sha256 != StreamDigest::new(self.stderr.clone().finalize().into()) {
|
||||||
|
return Err(ResultWindowError::StderrDigestMismatch);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn highest_contiguous_sequence(&self) -> Option<u64> {
|
||||||
|
self.next_sequence.checked_sub(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
494
crates/lazaret-core/tests/protocol.rs
Normal file
494
crates/lazaret-core/tests/protocol.rs
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, CoordinatorId, DomainId,
|
||||||
|
GenerationId, RequestDigest, RequestId, SandboxId, SessionId, StreamDigest,
|
||||||
|
TrustManifestDigest,
|
||||||
|
};
|
||||||
|
use lazaret_core::protocol::{
|
||||||
|
MAX_RESULT_CHUNK_BYTES, MAX_UNACKNOWLEDGED_CHUNKS, ProtocolCodecError, ProtocolValidationError,
|
||||||
|
RequestReplayGuard, ResultCollector, ResultSenderWindow, ResultWindowError, VerifiedSession,
|
||||||
|
encode_signed, issue_protocol_credential, issue_session_capability, issue_trust_manifest,
|
||||||
|
request_digest, stream_digest, verify_protocol_credential, verify_tls_credential,
|
||||||
|
verify_trust_manifest,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{
|
||||||
|
DeveloperDecision, DeveloperDecisionKind, EscalationOutcome, GenerationIdentity,
|
||||||
|
MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk,
|
||||||
|
ProtocolCredential, ProtocolCredentialRole, ProtocolSubjectId, ProtocolVersion, ResultStream,
|
||||||
|
SessionBinding, SessionRequestCapability, TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn bytes(value: u8) -> [u8; 32] {
|
||||||
|
[value; 32]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(value: u8) -> SigningKey {
|
||||||
|
SigningKey::from_bytes(&bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generation(value: u8) -> GenerationIdentity {
|
||||||
|
GenerationIdentity {
|
||||||
|
agent_profile: format!("agent-{value}"),
|
||||||
|
domain_id: DomainId::new(bytes(value)),
|
||||||
|
generation_id: GenerationId::new(u64::from(value)),
|
||||||
|
configuration_fingerprint: ConfigurationFingerprint::new(bytes(value + 1)),
|
||||||
|
coordinator_id: CoordinatorId::new(bytes(value + 2)),
|
||||||
|
sandbox_id: SandboxId::new(bytes(value + 3)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
generation: GenerationIdentity,
|
||||||
|
tls_ca_digest: CertificateDigest,
|
||||||
|
manifest: TrustManifest,
|
||||||
|
manifest_digest: TrustManifestDigest,
|
||||||
|
coordinator_credential: ProtocolCredential,
|
||||||
|
sandbox_credential: ProtocolCredential,
|
||||||
|
capability: SessionRequestCapability,
|
||||||
|
binding: SessionBinding,
|
||||||
|
coordinator_key: SigningKey,
|
||||||
|
sandbox_key: SigningKey,
|
||||||
|
anchor_key: SigningKey,
|
||||||
|
session: VerifiedSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let generation = generation(1);
|
||||||
|
let tls_ca_digest = CertificateDigest::new(bytes(20));
|
||||||
|
let issuer = key(21);
|
||||||
|
let coordinator_key = key(22);
|
||||||
|
let sandbox_key = key(23);
|
||||||
|
let anchor_key = key(24);
|
||||||
|
let (manifest, manifest_digest) =
|
||||||
|
issue_trust_manifest(generation.clone(), tls_ca_digest, &issuer).unwrap();
|
||||||
|
let coordinator_credential = issue_protocol_credential(
|
||||||
|
generation.clone(),
|
||||||
|
ProtocolCredentialRole::SessionCoordinator,
|
||||||
|
ProtocolSubjectId::SessionCoordinator(generation.coordinator_id),
|
||||||
|
&coordinator_key.verifying_key(),
|
||||||
|
&issuer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let sandbox_credential = issue_protocol_credential(
|
||||||
|
generation.clone(),
|
||||||
|
ProtocolCredentialRole::AgentSandbox,
|
||||||
|
ProtocolSubjectId::AgentSandbox(generation.sandbox_id),
|
||||||
|
&sandbox_key.verifying_key(),
|
||||||
|
&issuer,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let binding = SessionBinding {
|
||||||
|
generation_identity: generation.clone(),
|
||||||
|
cli_id: ClientId::new(bytes(25)),
|
||||||
|
session_id: SessionId::new(bytes(26)),
|
||||||
|
session_anchor_public_key: anchor_key.verifying_key().to_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
let capability = issue_session_capability(
|
||||||
|
binding.clone(),
|
||||||
|
coordinator_credential.credential_id,
|
||||||
|
sandbox_credential.credential_id,
|
||||||
|
&coordinator_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let session = VerifiedSession::new(
|
||||||
|
manifest_digest,
|
||||||
|
tls_ca_digest,
|
||||||
|
&manifest,
|
||||||
|
binding.clone(),
|
||||||
|
&coordinator_credential,
|
||||||
|
&sandbox_credential,
|
||||||
|
capability.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Fixture {
|
||||||
|
generation,
|
||||||
|
tls_ca_digest,
|
||||||
|
manifest,
|
||||||
|
manifest_digest,
|
||||||
|
coordinator_credential,
|
||||||
|
sandbox_credential,
|
||||||
|
capability,
|
||||||
|
binding,
|
||||||
|
coordinator_key,
|
||||||
|
sandbox_key,
|
||||||
|
anchor_key,
|
||||||
|
session,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(fixture: &Fixture, id: u8) -> MailboxRequest {
|
||||||
|
MailboxRequest::new(
|
||||||
|
fixture.binding.clone(),
|
||||||
|
RequestId::new(bytes(id)),
|
||||||
|
fixture.capability.clone(),
|
||||||
|
fixture.sandbox_credential.credential_id,
|
||||||
|
vec![b"/usr/bin/id".to_vec(), b"-u".to_vec()],
|
||||||
|
"show the numeric user identity".into(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_session_tuple_is_authenticated_and_request_ids_do_not_replay() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let request = request(&fixture, 30);
|
||||||
|
let encoded = encode_signed(&request, &fixture.sandbox_key).unwrap();
|
||||||
|
let mut replay = RequestReplayGuard::default();
|
||||||
|
let (accepted, digest) = replay.admit(&fixture.session, &encoded).unwrap();
|
||||||
|
assert_eq!(accepted, request);
|
||||||
|
assert_eq!(
|
||||||
|
digest,
|
||||||
|
request_digest(&postcard::to_stdvec(&request).unwrap())
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
replay.admit(&fixture.session, &encoded),
|
||||||
|
Err(ProtocolValidationError::RequestReplay)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut tampered = encoded.clone();
|
||||||
|
let last = tampered.last_mut().unwrap();
|
||||||
|
*last ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.session.verify_request(&tampered),
|
||||||
|
Err(ProtocolValidationError::Codec(
|
||||||
|
ProtocolCodecError::InvalidSignature
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
let wrong_key = encode_signed(&request, &key(31)).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.session.verify_request(&wrong_key),
|
||||||
|
Err(ProtocolValidationError::Codec(
|
||||||
|
ProtocolCodecError::InvalidSignature
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut stale_binding = fixture.binding.clone();
|
||||||
|
stale_binding.generation_identity.generation_id = GenerationId::new(2);
|
||||||
|
let stale = MailboxRequest::new(
|
||||||
|
stale_binding,
|
||||||
|
RequestId::new(bytes(32)),
|
||||||
|
fixture.capability.clone(),
|
||||||
|
fixture.sandbox_credential.credential_id,
|
||||||
|
vec![b"/usr/bin/true".to_vec()],
|
||||||
|
String::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let stale = encode_signed(&stale, &fixture.sandbox_key).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.session.verify_request(&stale),
|
||||||
|
Err(ProtocolValidationError::SessionBindingMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trust_credentials_refuse_key_role_generation_and_digest_confusion() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let issuer = verify_trust_manifest(
|
||||||
|
&fixture.manifest,
|
||||||
|
fixture.manifest_digest,
|
||||||
|
&fixture.generation,
|
||||||
|
fixture.tls_ca_digest,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
verify_trust_manifest(
|
||||||
|
&fixture.manifest,
|
||||||
|
TrustManifestDigest::new(bytes(99)),
|
||||||
|
&fixture.generation,
|
||||||
|
fixture.tls_ca_digest,
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::ManifestDigestMismatch)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
verify_protocol_credential(
|
||||||
|
&fixture.sandbox_credential,
|
||||||
|
&issuer,
|
||||||
|
&fixture.generation,
|
||||||
|
ProtocolCredentialRole::SessionCoordinator,
|
||||||
|
ProtocolSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::RoleMismatch)
|
||||||
|
));
|
||||||
|
let stale_generation = generation(40);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_protocol_credential(
|
||||||
|
&fixture.sandbox_credential,
|
||||||
|
&issuer,
|
||||||
|
&stale_generation,
|
||||||
|
ProtocolCredentialRole::AgentSandbox,
|
||||||
|
ProtocolSubjectId::AgentSandbox(fixture.generation.sandbox_id),
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::GenerationMismatch)
|
||||||
|
));
|
||||||
|
let mut tampered_credential = fixture.sandbox_credential.clone();
|
||||||
|
tampered_credential.public_key[0] ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
verify_protocol_credential(
|
||||||
|
&tampered_credential,
|
||||||
|
&issuer,
|
||||||
|
&fixture.generation,
|
||||||
|
ProtocolCredentialRole::AgentSandbox,
|
||||||
|
ProtocolSubjectId::AgentSandbox(fixture.generation.sandbox_id),
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::CredentialDigestMismatch)
|
||||||
|
));
|
||||||
|
let mut tampered_capability = fixture.capability.clone();
|
||||||
|
tampered_capability.capability_id = CapabilityId::new(bytes(98));
|
||||||
|
assert!(matches!(
|
||||||
|
VerifiedSession::new(
|
||||||
|
fixture.manifest_digest,
|
||||||
|
fixture.tls_ca_digest,
|
||||||
|
&fixture.manifest,
|
||||||
|
fixture.binding.clone(),
|
||||||
|
&fixture.coordinator_credential,
|
||||||
|
&fixture.sandbox_credential,
|
||||||
|
tampered_capability,
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::CapabilityDigestMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_credentials_refuse_role_and_certificate_digest_confusion() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let certificate = b"synthetic DER fixture".to_vec();
|
||||||
|
let tls = TlsCredential::session_coordinator(
|
||||||
|
fixture.generation.clone(),
|
||||||
|
fixture.generation.coordinator_id,
|
||||||
|
certificate.clone(),
|
||||||
|
CertificateDigest::new(
|
||||||
|
lazaret_core::protocol::stream_digest(&certificate)
|
||||||
|
.as_bytes()
|
||||||
|
.to_owned(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
verify_tls_credential(
|
||||||
|
&tls,
|
||||||
|
&fixture.generation,
|
||||||
|
TlsRole::SessionCoordinator,
|
||||||
|
TlsSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
verify_tls_credential(
|
||||||
|
&tls,
|
||||||
|
&fixture.generation,
|
||||||
|
TlsRole::ClientCli,
|
||||||
|
TlsSubjectId::ClientCli(ClientId::new(bytes(1))),
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::RoleMismatch)
|
||||||
|
));
|
||||||
|
let invalid_tls = TlsCredential::session_coordinator(
|
||||||
|
fixture.generation.clone(),
|
||||||
|
fixture.generation.coordinator_id,
|
||||||
|
certificate,
|
||||||
|
CertificateDigest::new(bytes(1)),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_tls_credential(
|
||||||
|
&invalid_tls,
|
||||||
|
&fixture.generation,
|
||||||
|
TlsRole::SessionCoordinator,
|
||||||
|
TlsSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::CertificateDigestMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_anchor_and_sandbox_signatures_are_not_interchangeable() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let request = request(&fixture, 50);
|
||||||
|
let request_bytes = encode_signed(&request, &fixture.sandbox_key).unwrap();
|
||||||
|
let (_, digest) = fixture.session.verify_request(&request_bytes).unwrap();
|
||||||
|
let decision = DeveloperDecision {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding.clone(),
|
||||||
|
request_id: request.request_id(),
|
||||||
|
request_sha256: digest,
|
||||||
|
decision: DeveloperDecisionKind::Approve,
|
||||||
|
approved_command_argv: request.command_argv().to_vec(),
|
||||||
|
};
|
||||||
|
let signed = encode_signed(&decision, &fixture.anchor_key).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.session
|
||||||
|
.verify_developer_decision(&signed, request.request_id(), digest)
|
||||||
|
.unwrap(),
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
let sandbox_signed = encode_signed(&decision, &fixture.sandbox_key).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
fixture
|
||||||
|
.session
|
||||||
|
.verify_developer_decision(&sandbox_signed, request.request_id(), digest),
|
||||||
|
Err(ProtocolValidationError::Codec(
|
||||||
|
ProtocolCodecError::InvalidSignature
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_ack_chunks_and_terminal_result_bind_the_accepted_request() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let request = request(&fixture, 60);
|
||||||
|
let request_id = request.request_id();
|
||||||
|
let (_, digest) = fixture
|
||||||
|
.session
|
||||||
|
.verify_request(&encode_signed(&request, &fixture.sandbox_key).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cancellation = MailboxCancellation {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding.clone(),
|
||||||
|
request_id,
|
||||||
|
session_capability: fixture.capability.clone(),
|
||||||
|
sandbox_credential_id: fixture.sandbox_credential.credential_id,
|
||||||
|
};
|
||||||
|
let cancellation = encode_signed(&cancellation, &fixture.sandbox_key).unwrap();
|
||||||
|
fixture
|
||||||
|
.session
|
||||||
|
.verify_cancellation(&cancellation, request_id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ack = MailboxResultAck {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding.clone(),
|
||||||
|
request_id,
|
||||||
|
request_sha256: digest,
|
||||||
|
session_capability: fixture.capability.clone(),
|
||||||
|
sandbox_credential_id: fixture.sandbox_credential.credential_id,
|
||||||
|
highest_contiguous_sequence: 1,
|
||||||
|
};
|
||||||
|
let ack = encode_signed(&ack, &fixture.sandbox_key).unwrap();
|
||||||
|
fixture
|
||||||
|
.session
|
||||||
|
.verify_result_ack(&ack, request_id, digest)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let chunks = [
|
||||||
|
(ResultStream::Stdout, b"hello".as_slice()),
|
||||||
|
(ResultStream::Stderr, b"warning".as_slice()),
|
||||||
|
];
|
||||||
|
let mut collector = ResultCollector::default();
|
||||||
|
for (sequence, (stream, bytes)) in chunks.iter().enumerate() {
|
||||||
|
let chunk = MailboxResultChunk {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding.clone(),
|
||||||
|
request_id,
|
||||||
|
request_sha256: digest,
|
||||||
|
coordinator_credential_id: fixture.coordinator_credential.credential_id,
|
||||||
|
sequence: u64::try_from(sequence).unwrap(),
|
||||||
|
stream: *stream,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
};
|
||||||
|
let encoded = encode_signed(&chunk, &fixture.coordinator_key).unwrap();
|
||||||
|
let verified = fixture
|
||||||
|
.session
|
||||||
|
.verify_result_chunk(&encoded, request_id, digest)
|
||||||
|
.unwrap();
|
||||||
|
collector.push(&verified).unwrap();
|
||||||
|
if sequence == 0 {
|
||||||
|
assert_eq!(
|
||||||
|
collector.push(&verified),
|
||||||
|
Err(ResultWindowError::UnexpectedSequence {
|
||||||
|
expected: 1,
|
||||||
|
actual: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = MailboxResult {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding.clone(),
|
||||||
|
request_id,
|
||||||
|
request_sha256: digest,
|
||||||
|
coordinator_credential_id: fixture.coordinator_credential.credential_id,
|
||||||
|
outcome: EscalationOutcome::Completed,
|
||||||
|
exit_code: Some(0),
|
||||||
|
chunk_count: 2,
|
||||||
|
stdout_sha256: stream_digest(b"hello"),
|
||||||
|
stderr_sha256: stream_digest(b"warning"),
|
||||||
|
};
|
||||||
|
let encoded = encode_signed(&result, &fixture.coordinator_key).unwrap();
|
||||||
|
let verified = fixture
|
||||||
|
.session
|
||||||
|
.verify_result(&encoded, request_id, digest)
|
||||||
|
.unwrap();
|
||||||
|
collector.finish(&verified).unwrap();
|
||||||
|
|
||||||
|
let mut wrong_digest = result;
|
||||||
|
wrong_digest.stdout_sha256 = StreamDigest::new(bytes(1));
|
||||||
|
assert_eq!(
|
||||||
|
collector.finish(&wrong_digest),
|
||||||
|
Err(ResultWindowError::StdoutDigestMismatch)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
fixture
|
||||||
|
.session
|
||||||
|
.verify_result(&encoded, request_id, RequestDigest::new(bytes(99))),
|
||||||
|
Err(ProtocolValidationError::RequestMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_windows_are_bounded_and_contiguous() {
|
||||||
|
let mut sender = ResultSenderWindow::default();
|
||||||
|
for expected in 0..MAX_UNACKNOWLEDGED_CHUNKS {
|
||||||
|
assert_eq!(sender.reserve(1).unwrap(), u64::try_from(expected).unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(sender.reserve(1), Err(ResultWindowError::WindowFull));
|
||||||
|
sender.acknowledge(3).unwrap();
|
||||||
|
assert_eq!(sender.outstanding(), 4);
|
||||||
|
assert_eq!(sender.acknowledge(3), Err(ResultWindowError::AckRegression));
|
||||||
|
assert_eq!(
|
||||||
|
sender.acknowledge(99),
|
||||||
|
Err(ResultWindowError::AckBeyondSent)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sender.reserve(MAX_RESULT_CHUNK_BYTES + 1),
|
||||||
|
Err(ResultWindowError::ChunkTooLarge {
|
||||||
|
length: MAX_RESULT_CHUNK_BYTES + 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let fixture = fixture();
|
||||||
|
let oversized = MailboxResultChunk {
|
||||||
|
protocol_version: ProtocolVersion::V1,
|
||||||
|
session_binding: fixture.binding,
|
||||||
|
request_id: RequestId::new(bytes(70)),
|
||||||
|
request_sha256: RequestDigest::new(bytes(71)),
|
||||||
|
coordinator_credential_id: fixture.coordinator_credential.credential_id,
|
||||||
|
sequence: 0,
|
||||||
|
stream: ResultStream::Stdout,
|
||||||
|
bytes: vec![0; MAX_RESULT_CHUNK_BYTES + 1],
|
||||||
|
};
|
||||||
|
let encoded = encode_signed(&oversized, &fixture.coordinator_key).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.session.verify_result_chunk(
|
||||||
|
&encoded,
|
||||||
|
RequestId::new(bytes(70)),
|
||||||
|
RequestDigest::new(bytes(71))
|
||||||
|
),
|
||||||
|
Err(ProtocolValidationError::ChunkTooLarge { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_signed_record_limit_is_enforced() {
|
||||||
|
let fixture = fixture();
|
||||||
|
let oversized = MailboxRequest::new(
|
||||||
|
fixture.binding,
|
||||||
|
RequestId::new(bytes(80)),
|
||||||
|
fixture.capability,
|
||||||
|
fixture.sandbox_credential.credential_id,
|
||||||
|
vec![b"/usr/bin/true".to_vec()],
|
||||||
|
"x".repeat(65_536),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
encode_signed(&oversized, &fixture.sandbox_key),
|
||||||
|
Err(ProtocolCodecError::RecordTooLarge { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@ skip = [
|
||||||
{ name = "oci-spec", version = "0.9.0", reason = "oci-client 0.17.0 uses its current OCI types while the runtime supervisor directly uses 0.10.0" },
|
{ name = "oci-spec", version = "0.9.0", reason = "oci-client 0.17.0 uses its current OCI types while the runtime supervisor directly uses 0.10.0" },
|
||||||
{ name = "untrusted", version = "0.7.1", reason = "oci-client rustls authentication uses aws-lc-rs's compatibility line" },
|
{ name = "untrusted", version = "0.7.1", reason = "oci-client rustls authentication uses aws-lc-rs's compatibility line" },
|
||||||
]
|
]
|
||||||
|
skip-tree = [
|
||||||
|
{ name = "sha2", version = "=0.10.9", depth = 2, reason = "ed25519-dalek 2.2.0 uses the SHA-2 0.10 line" },
|
||||||
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
allow = [
|
allow = [
|
||||||
|
|
|
||||||
|
|
@ -869,6 +869,22 @@ classDiagram
|
||||||
Host Coordinator. It is not a distinct Host Coordinator principal. Physical
|
Host Coordinator. It is not a distinct Host Coordinator principal. Physical
|
||||||
encoding, signing, size, and validation rules are defined by the specification.
|
encoding, signing, size, and validation rules are defined by the specification.
|
||||||
|
|
||||||
|
For embedded signed claims, the field order shown above is the Postcard field
|
||||||
|
order. A protocol credential ID is SHA-256 over the minimally encoded claim
|
||||||
|
fields excluding `credential_id` and `issuer_signature`. A Session Request
|
||||||
|
Capability ID is SHA-256 over the minimally encoded claim fields excluding
|
||||||
|
`capability_id` and `coordinator_signature`. A Trust Manifest digest is SHA-256
|
||||||
|
over the complete minimally encoded manifest, including its issuer signature.
|
||||||
|
The manifest issuer signs its minimally encoded fields excluding
|
||||||
|
`issuer_signature`.
|
||||||
|
|
||||||
|
Every signature input is the ASCII `lazaret/v1/` prefix plus the lower-kebab
|
||||||
|
record name, followed by the unsigned 64-bit big-endian payload length and the
|
||||||
|
exact payload bytes. Mailbox signatures use the bytes carried in
|
||||||
|
`SignedProtocolRecord`; verification occurs before decoding those payload bytes.
|
||||||
|
Result stream digests are SHA-256 over the ordered concatenation of bytes for
|
||||||
|
that stream.
|
||||||
|
|
||||||
### Participant ownership
|
### Participant ownership
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue