diff --git a/.gitattributes b/.gitattributes index 4506bbe..5dea7f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,12 @@ *.sh text eol=lf *.service text eol=lf +libraries/tacacsrs_config/src/generated.rs text eol=lf +libraries/tacacsrs_config/yang/*.ini text eol=lf +libraries/tacacsrs_config/yang/*.json text eol=lf +libraries/tacacsrs_config/yang/*.py text eol=lf +libraries/tacacsrs_config/yang/*.txt text eol=lf + executables/*/debian/maintainer-scripts/* text eol=lf libraries/*/debian/maintainer-scripts/* text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 73f5f86..9872db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,6 +1880,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2066,6 +2072,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tacacsrs-credential-resolution" +version = "0.0.0-dev" +dependencies = [ + "async-trait", + "serde", + "static_assertions", + "tacacsrs-config", + "tokio", + "zeroize", +] + [[package]] name = "tacacsrs-datastore" version = "0.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 031d77e..55de761 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "libraries/tacacsrs_agent_ipc_emulator", "libraries/tacacsrs_agent", "libraries/tacacsrs_config", + "libraries/tacacsrs_credential_resolution", "libraries/tacacsrs_datastore", "libraries/tacacsrs_cli_datastore", "libraries/tacacsrs_sonic", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index edb68bf..afa89a9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -243,6 +243,26 @@ cp generated_types.rs ../src/generated.rs `expand_yang_tree.py` fetches the upstream IETF YANG modules and passes local project modules from `libraries/tacacsrs_config/yang/modules/` to `pyang`. The `feature-flags.ini` file controls both upstream features and project features such as `tacacsrs:psk-dhe-ke-hello-params`. +The upstream `YangModels/yang` commit is pinned in `expand_yang_tree.py` and recorded with source/input/output SHA-256 values in `generation-manifest.json`. The cache must be a detached HEAD at that exact commit. Use `--clean` to deliberately replace a stale cache; the generator never silently uses another revision. + +Verify source identity, manifest hashes, two-run determinism, and checked-in output before committing generated changes: + +```bash +cd libraries/tacacsrs_config/yang +python -m unittest -v test_expand_yang_tree.py +python verify_generated.py --clean +``` + +The verifier compares canonical LF content so Windows and Linux checkouts produce the same result. `.gitattributes` keeps generation inputs and outputs at LF. Do not recreate `feature-flags.ini` with `--list-features` without reviewing every value because that command emits all discovered features as enabled and can erase deliberate `false` selections. + +The reviewed feature map enables RFC 9950 central keystore and central truststore support. Generated direct and bundled model paths include: + +- structured central asymmetric-key and certificate references for client certificate identity; +- a central symmetric-key reference for TLS 1.3 EPSK while preserving identity, hash, context, target, and group fields; +- central CA and end-entity certificate-bag references for server authentication. + +Do not hand-edit these shapes in `src/generated.rs`. `tacacsrs-config` validates generated inline-versus-central choices and preserves central values as opaque strings. It expands only config-local bundles. `tacacsrs-credential-resolution` owns provider-neutral request planning, secret-safe material, and closed result matching. SONiC reference grammar, filesystem retrieval, watching, permission checks, refresh policy, and runtime networking projection belong to the P3 provider/integration layer. + After regenerating, run the workspace formatting, clippy, build, and test commands before committing to ensure the emitted code still matches repository expectations. ## CI/CD Overview diff --git a/README.md b/README.md index 302b7d1..eb6fb86 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ See the [Plain TACACS+ to TACACS+ over TLS Transition Guide](docs/tacacs-plus-tl | **tacon** | Interactive TACACS+ test application for authentication, authorization, accounting, and transport validation across plain TCP, TLS, TLS mTLS, TLS PSK, and TLS PSK-DHE | | **tacacsrs-agentd** | Central daemon executable that hosts `tacacsrs-agent`, maintains persistent upstream connections, and provides automatic failover | | **tacacsrs-agent-ipc-emulatord** | OPA/Rego-driven gRPC IPC emulator for integration tests that exercise `ServiceClient` clients without a live daemon | -| **tacacsrs-config** | YANG JSON configuration crate for `ietf-system-tacacs-plus` parsing, validation, and runtime mapping | +| **tacacsrs-config** | Open YANG JSON model for `ietf-system-tacacs-plus` parsing, validation, and config-local bundle enumeration | +| **tacacsrs-credential-resolution** | Provider-neutral central credential plans, secret-safe resolved material, and closed request/result matching | | **session-wrapper** | Linux session wrapper POC for TACACS+ command authorization via seccomp user notifications | ## Workspace Architecture @@ -53,9 +54,11 @@ tacacsrs-agent ────┬──► tacacsrs-agent-client tacacsrs-agent-ipc-emulatord ───► tacacsrs-agent-ipc-emulator └──► tacacsrs-agent-client + +tacacsrs-credential-resolution ──► tacacsrs-config ``` -`tacacsrs-config` is the entry point for RFC 7951 YANG JSON parsing. It owns the generated `ietf-system-tacacs-plus` Rust types, resolves credential references, validates YANG-specific choice constraints, and maps validated data into runtime connection settings shared by `tacon` and `tacacsrs-agentd`. +`tacacsrs-config` is the entry point for RFC 7951 YANG JSON parsing. It owns the generated `ietf-system-tacacs-plus` Rust types, validates YANG-specific constraints, and expands config-local credential bundles while preserving external central references as opaque values. `tacacsrs-credential-resolution` turns those references into typed provider-neutral requests and validates resolved results. Provider-specific retrieval and runtime connection projection are separate integration concerns. ## Documentation @@ -67,6 +70,7 @@ tacacsrs-agent-ipc-emulatord ───► tacacsrs-agent-ipc-emulator - [Plain TACACS+ to TACACS+ over TLS Transition Guide](docs/tacacs-plus-tls-transition.md) — Local proxy cutover plan for `pam_tacplus`, `audisp-tacplus`, and similar clients - [tacacsrs-agent-ipc-emulator README](libraries/tacacsrs_agent_ipc_emulator/README.md) — Rego policy format and in-process/out-of-process IPC emulator usage - [tacacsrs-config README](libraries/tacacsrs_config/README.md) — YANG JSON schema support, codegen workflow, parsing APIs +- [tacacsrs-credential-resolution README](libraries/tacacsrs_credential_resolution/README.md) — Central credential planning, resolver contracts, and secret-safe material - [session-wrapper README](executables/session_wrapper/README.md) — Linux seccomp session wrapper architecture and current allow-all behavior - [Session Wrapper Deployment Guide](docs/session-wrapper.md) — SSH `ForceCommand` integration, configuration examples, security notes, troubleshooting - [Session Wrapper Testing](docs/session-wrapper-testing.md) — Linux smoke and integration checks for the session wrapper diff --git a/docs/yang-config-guide.md b/docs/yang-config-guide.md index 1a7b0b1..29997df 100644 --- a/docs/yang-config-guide.md +++ b/docs/yang-config-guide.md @@ -10,10 +10,11 @@ Use this guide when you need to author, validate, or consume TACACS+ server conf - RFC 7951 JSON parsing into the generated TACACS+ model. - Validation for required server data, unique addresses, TLS choice constraints, SNI requirements, key format identities, base64-encoded key material, and config-local credential references. - Bundle enumeration helpers that inline local `client-credentials` and `server-credentials` references onto server entries. +- Secret-free inspection of central keystore and truststore references. - A `TacacsPlusServerBuilder` for constructing valid server definitions in Rust. - A project-owned TACACS+/TLS augmentation for TLS 1.3 PSK-DHE key exchange group selection. -External secret providers are intentionally kept outside this crate. Parse and enumerate configuration first, then pass the resulting server values to a runtime or provider layer that can materialize external secrets. +Central references remain opaque in this crate. Parse and enumerate configuration first, then use `tacacsrs-credential-resolution` to build provider-neutral requests and validate resolved results. Provider-specific retrieval and runtime projection remain separate integration concerns. ## Basic JSON shape @@ -59,6 +60,7 @@ The primary entry points are: | `validate_credential_references(&TacacsPlus)` | Validate config-local credential bundle references. | | `enumerate_servers(&TacacsPlus)` | Inline shared credential bundles onto every server. | | `enumerate_server(&TacacsPlus, &str)` | Inline shared credential bundles for one named server. | +| `inspect_central_references(&TacacsPlusServer)` | Inspect typed central usages without retrieving material. | The module-oriented API is also available for callers that want grouped imports: @@ -98,9 +100,11 @@ let server = enumerate_server(&config, "primary")?; # anyhow::Ok::<()>(()) ``` -### External secret resolution +### Central credential resolution -External secret material should be resolved after enumeration by a separate runtime or provider layer. That keeps the generated configuration model safe for logging, reporting, and round-tripping while runtime code receives normalized server values. +Build `tacacsrs_credential_resolution::ResolutionPlan` values after enumeration. The resolution crate emits deterministic requests for central certificate-with-key, TLS 1.3 symmetric key, CA bag, and end-entity bag usages. Its async resolver contract returns secret-safe typed material, and `resolve_plan` rejects incomplete or mismatched result sets. + +Neither generic crate interprets a central string as a SONiC identifier or filesystem path. P3 supplies provider retrieval, refresh behavior, and projection into networking inputs. ## Programmatic server construction diff --git a/libraries/tacacsrs_config/README.md b/libraries/tacacsrs_config/README.md index 3c9428a..d1cedc8 100644 --- a/libraries/tacacsrs_config/README.md +++ b/libraries/tacacsrs_config/README.md @@ -9,10 +9,11 @@ - Validation logic for YANG-specific constraints and semantic checks on inline key material - Config-local credential bundle validation - Per-server bundle enumeration helpers for `client-credentials` and `server-credentials` +- Secret-free inspection of central keystore and truststore references on enumerated servers - A reusable `TacacsPlusServerBuilder` for constructing `TacacsPlusServer` values in code - A project-owned YANG augmentation for TLS 1.3 PSK DHE key exchange group selection -Any future external secret resolution and materialization should live in a separate runtime/provider crate rather than in `tacacsrs-config`. +External secret resolution is defined by `tacacsrs-credential-resolution`. Provider implementations and runtime materialization remain outside `tacacsrs-config`. ## Parsing API @@ -31,8 +32,9 @@ The primary entry points are: - `validate_credential_references(&TacacsPlus)` — validate config-local `credentials-reference` links into shared bundles - `enumerate_servers(&TacacsPlus)` — inline shared credential bundles onto each `TacacsPlusServer` - `enumerate_server(&TacacsPlus, &str)` — inline shared credential bundles for one named server +- `inspect_central_references(&TacacsPlusServer)` — inspect opaque central references without retrieving secret material -If external secret providers are introduced, enumerate the servers first and then pass the resulting `TacacsPlusServer` values to that runtime/provider layer. +Enumerate servers before passing them to `tacacsrs-credential-resolution`. Planning rejects unresolved config-local bundle references with an enumerate-first error. ## Multi-layer design @@ -66,10 +68,13 @@ validate_credential_references(&config)?; let server = enumerate_server(&config, "primary")?; ``` -### 3) External secret resolution (separate crate) +### 3) Central credential resolution -External secret providers are intentionally handled outside this crate. -After enumeration, pass the resulting `TacacsPlusServer` values to a separate runtime/provider layer for any optional external validation and secret materialization. +Pass each enumerated server to `tacacsrs_credential_resolution::ResolutionPlan::from_server`. The resolution crate extracts deterministic typed requests for central certificate-with-key, TLS 1.3 symmetric key, CA bag, and end-entity bag references. A `CredentialResolver` returns typed material, and `resolve_plan` validates the complete slot/variant-matched result set. + +Central references are opaque in both generic crates. They may contain spaces, slashes, traversal-like text, or provider-defined syntax. `tacacsrs-config` enforces generated YANG structure and inline-versus-central choices, but it does not apply SONiC identifier grammar, map references to paths, test existence or permissions, watch files, or retrieve secrets. + +P3 provides the SONiC-specific resolver and projects the closed result set into networking inputs. ### Module-oriented API (recommended for most users) @@ -128,13 +133,14 @@ assert!(server.is_obfuscation()); The builder is intentionally small. It is meant for runtime construction of valid server shapes, not as a replacement for schema validation or full YANG parsing. -### Future external crypto integration +### Central crypto integration boundary The intended long-term split is: - `tacacsrs-config` stays as the open configuration model. It owns RFC 7951 parsing, schema validation, bundle enumeration, and generated YANG types. - Small derived helpers that are valid before and after secret resolution belong here, in `extensions`, on top of `TacacsPlusServer` and other generated types. -- External crypto providers should sit behind a separate boundary that consumes enumerated `TacacsPlusServer` values and returns a closed runtime representation with concrete material. +- `tacacsrs-credential-resolution` consumes enumerated `TacacsPlusServer` values and returns a closed provider-neutral result set with concrete material. +- Provider implementations and projection into connection-ready networking types remain integration-layer responsibilities. That keeps the generated config model optimized for round-tripping and reporting, while runtime code gets a provider-agnostic handoff with only the normalized fields needed to connect. @@ -146,7 +152,7 @@ The crate includes runnable examples under `examples/`: - `quick_start_credential_refs.rs` — minimal end-to-end example showing bundle validation and enumeration - `pipeline_flow.rs` — explicit step-by-step parse/enumerate/external-resolution pipeline - `model_access.rs` — direct access to generated model types and flags -- External secret-resolution examples should live in a separate runtime/provider crate if that integration is added later. +- The provider-neutral central-resolution example lives in `tacacsrs-credential-resolution`. Run examples from the workspace root: @@ -155,7 +161,7 @@ cargo run -p tacacsrs-config --example quick_start cargo run -p tacacsrs-config --example quick_start_credential_refs cargo run -p tacacsrs-config --example pipeline_flow cargo run -p tacacsrs-config --example model_access -cargo run -p tacacsrs-config --example credential_references +cargo run -p tacacsrs-credential-resolution --example central_resolution ``` ## Public API surface @@ -189,7 +195,7 @@ Validation checks include: - Credential references have matching definitions in the same config - Config-local credential references have matching definitions -To resolve external credentials and materialize them for runtime use, introduce a separate runtime/provider layer after enumeration. +To resolve central credentials, create a `ResolutionPlan` after enumeration and execute it through a `CredentialResolver`. Runtime projection remains outside both generic crates. This design separates parsing/validation from credential retrieval and enables round-trip safety. @@ -222,7 +228,7 @@ For advanced use cases, these lower-level functions are available: ### 3) Runtime types -If runtime secret-materialized server types are introduced later, they should live outside `tacacsrs-config` in a dedicated runtime/provider crate. +Secret-bearing result types live in `tacacsrs-credential-resolution`; connection-ready server types remain outside `tacacsrs-config`. Shared derived helpers for the generated server model live in `TacacsPlusServerExt`. Programmatic construction helpers for the generated server model live in `TacacsPlusServerBuilder`. diff --git a/libraries/tacacsrs_config/src/builders.rs b/libraries/tacacsrs_config/src/builders.rs index 45b56fa..9fcc1f5 100644 --- a/libraries/tacacsrs_config/src/builders.rs +++ b/libraries/tacacsrs_config/src/builders.rs @@ -207,6 +207,7 @@ impl TacacsPlusServerBuilder { cleartext_private_key, cert_data, }), + central_keystore_reference: None, }), tls13_epsk: None, }); @@ -259,6 +260,7 @@ impl TacacsPlusServerBuilder { key_format: None, cleartext_symmetric_key: Some(cleartext_symmetric_key), }), + central_keystore_reference: None, external_identity: external_identity.into(), hash: EpskSupportedHash::Sha256, context: None, diff --git a/libraries/tacacsrs_config/src/central_references.rs b/libraries/tacacsrs_config/src/central_references.rs new file mode 100644 index 0000000..b459ab9 --- /dev/null +++ b/libraries/tacacsrs_config/src/central_references.rs @@ -0,0 +1,252 @@ +//! Provider-neutral inspection of unresolved central credential references. +//! +//! This module borrows generated model values and emits deterministic slots. +//! It performs no provider I/O and does not interpret opaque reference values. + +use std::fmt; + +use crate::TacacsPlusServer; + +/// RFC credential usage represented by one central reference slot. +#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum CentralCredentialUsage { + /// TLS client certificate and its private key. + ClientCertificateWithKey, + /// TLS 1.3 external pre-shared key. + ClientTls13Epsk, + /// CA certificate bag used to authenticate a server chain. + ServerCaCertificateBag, + /// End-entity certificate bag used for exact server authentication. + ServerEeCertificateBag, +} + +impl CentralCredentialUsage { + /// Returns the stable RFC model field path for this usage. + #[must_use] + pub const fn field_path(self) -> &'static str { + match self { + Self::ClientCertificateWithKey => "client-identity/certificate", + Self::ClientTls13Epsk => "client-identity/tls13-epsk", + Self::ServerCaCertificateBag => "server-authentication/ca-certs", + Self::ServerEeCertificateBag => "server-authentication/ee-certs", + } + } +} + +/// Borrowed generated central reference for one credential usage. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum CentralCredentialReference<'a> { + /// Central certificate-with-key reference from RFC 9642. + CertificateWithKey { + /// Opaque asymmetric-key reference, when present in the generated model. + asymmetric_key: Option<&'a str>, + /// Opaque certificate reference, when present in the generated model. + certificate: Option<&'a str>, + }, + /// Opaque central symmetric-key reference used by TLS 1.3 EPSK. + SymmetricKey(&'a str), + /// Opaque central truststore certificate-bag reference. + CertificateBag(&'a str), +} + +impl fmt::Debug for CentralCredentialReference<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CertificateWithKey { .. } => formatter + .debug_struct("CertificateWithKey") + .field("reference", &"") + .finish(), + Self::SymmetricKey(_) => formatter + .debug_tuple("SymmetricKey") + .field(&"") + .finish(), + Self::CertificateBag(_) => formatter + .debug_tuple("CertificateBag") + .field(&"") + .finish(), + } + } +} + +/// Deterministic central credential slot borrowed from an enumerated server. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct CentralCredentialSlot<'a> { + server_name: &'a str, + usage: CentralCredentialUsage, + reference: CentralCredentialReference<'a>, +} + +impl CentralCredentialSlot<'_> { + /// Returns the configured server name used as stable request context. + #[must_use] + pub const fn server_name(&self) -> &str { + self.server_name + } + + /// Returns the RFC credential usage for this slot. + #[must_use] + pub const fn usage(&self) -> CentralCredentialUsage { + self.usage + } + + /// Explicitly exposes the borrowed opaque reference for resolver planning. + #[must_use] + pub const fn reference(&self) -> CentralCredentialReference<'_> { + self.reference + } +} + +impl fmt::Debug for CentralCredentialSlot<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CentralCredentialSlot") + .field("server_name", &self.server_name) + .field("usage", &self.usage) + .field("reference", &"") + .finish() + } +} + +/// Config-local credential field that must be enumerated before inspection. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum UnexpandedCredentialField { + /// `client-identity/credentials-reference` remains present. + ClientIdentity, + /// `server-authentication/credentials-reference` remains present. + ServerAuthentication, +} + +impl UnexpandedCredentialField { + const fn field_path(self) -> &'static str { + match self { + Self::ClientIdentity => "client-identity/credentials-reference", + Self::ServerAuthentication => "server-authentication/credentials-reference", + } + } +} + +/// Error returned when central inspection receives a server with a local bundle reference. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct EnumerationRequiredError { + server_name: String, + field: UnexpandedCredentialField, +} + +impl EnumerationRequiredError { + /// Returns the server whose local reference remains unresolved. + #[must_use] + pub fn server_name(&self) -> &str { + &self.server_name + } + + /// Returns the local credential field requiring enumeration. + #[must_use] + pub const fn field(&self) -> UnexpandedCredentialField { + self.field + } +} + +impl fmt::Display for EnumerationRequiredError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "server '{}': {} must be expanded with enumerate_server or enumerate_servers before central credential inspection", + self.server_name, + self.field.field_path(), + ) + } +} + +impl std::error::Error for EnumerationRequiredError {} + +/// Inspects central references on one direct or enumerated server. +/// +/// Slots are returned in stable field order: client certificate, client EPSK, +/// server CA bag, then server end-entity bag. Inline fields produce no slot. +/// Structurally incomplete generated central containers are preserved as slots; +/// the resolution planning layer decides whether it can form a usable request. +/// +/// # Errors +/// +/// Returns [`EnumerationRequiredError`] when a config-local client or server +/// credential bundle reference remains. The error deliberately omits the raw +/// reference value. +pub fn inspect_central_references( + server: &TacacsPlusServer, +) -> Result>, EnumerationRequiredError> { + if server + .client_identity + .as_ref() + .is_some_and(|identity| identity.credentials_reference.is_some()) + { + return Err(EnumerationRequiredError { + server_name: server.name.clone(), + field: UnexpandedCredentialField::ClientIdentity, + }); + } + if server + .server_authentication + .as_ref() + .is_some_and(|authentication| authentication.credentials_reference.is_some()) + { + return Err(EnumerationRequiredError { + server_name: server.name.clone(), + field: UnexpandedCredentialField::ServerAuthentication, + }); + } + + let mut slots = Vec::with_capacity(4); + if let Some(identity) = &server.client_identity { + if let Some(reference) = identity + .certificate + .as_ref() + .and_then(|certificate| certificate.central_keystore_reference.as_ref()) + { + slots.push(CentralCredentialSlot { + server_name: &server.name, + usage: CentralCredentialUsage::ClientCertificateWithKey, + reference: CentralCredentialReference::CertificateWithKey { + asymmetric_key: reference.asymmetric_key.as_deref(), + certificate: reference.certificate.as_deref(), + }, + }); + } + if let Some(reference) = identity + .tls13_epsk + .as_ref() + .and_then(|epsk| epsk.central_keystore_reference.as_deref()) + { + slots.push(CentralCredentialSlot { + server_name: &server.name, + usage: CentralCredentialUsage::ClientTls13Epsk, + reference: CentralCredentialReference::SymmetricKey(reference), + }); + } + } + if let Some(authentication) = &server.server_authentication { + if let Some(reference) = authentication + .ca_certs + .as_ref() + .and_then(|certificates| certificates.central_truststore_reference.as_deref()) + { + slots.push(CentralCredentialSlot { + server_name: &server.name, + usage: CentralCredentialUsage::ServerCaCertificateBag, + reference: CentralCredentialReference::CertificateBag(reference), + }); + } + if let Some(reference) = authentication + .ee_certs + .as_ref() + .and_then(|certificates| certificates.central_truststore_reference.as_deref()) + { + slots.push(CentralCredentialSlot { + server_name: &server.name, + usage: CentralCredentialUsage::ServerEeCertificateBag, + reference: CentralCredentialReference::CertificateBag(reference), + }); + } + } + + Ok(slots) +} diff --git a/libraries/tacacsrs_config/src/generated.rs b/libraries/tacacsrs_config/src/generated.rs index 3e87243..3144c07 100644 --- a/libraries/tacacsrs_config/src/generated.rs +++ b/libraries/tacacsrs_config/src/generated.rs @@ -160,6 +160,12 @@ pub mod tacacs_plus { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub inline_definition: Option, + /// A reference to a specific certificate associated with + /// an asymmetric key stored in the central keystore. + #[serde(rename = "central-keystore-reference")] + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub central_keystore_reference: Option, } /// Choice constraints for [`ClientIdentityCertificate`]. @@ -169,6 +175,7 @@ pub mod tacacs_plus { /// Each inner slice is one case; at most one case may have fields set. pub const CHOICE_INLINE_OR_KEYSTORE: &[(&str, &[&str])] = &[ ("inline", &["inline-definition"]), + ("central-keystore", &["central-keystore-reference"]), ]; pub const CHOICE_INLINE_OR_KEYSTORE_MANDATORY: bool = true; } @@ -184,6 +191,12 @@ pub mod tacacs_plus { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub inline_definition: Option, + /// A reference to a symmetric key that exists in + /// the central keystore. + #[serde(rename = "central-keystore-reference")] + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub central_keystore_reference: Option, /// A sequence of bytes used to identify an EPSK. A label for /// a PSK established externally. #[serde(rename = "external-identity")] @@ -225,6 +238,7 @@ pub mod tacacs_plus { /// Each inner slice is one case; at most one case may have fields set. pub const CHOICE_INLINE_OR_KEYSTORE: &[(&str, &[&str])] = &[ ("inline", &["inline-definition"]), + ("central-keystore", &["central-keystore-reference"]), ]; pub const CHOICE_INLINE_OR_KEYSTORE_MANDATORY: bool = true; } @@ -275,6 +289,12 @@ pub mod tacacs_plus { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub inline_definition: Option, + /// A reference to a certificate bag that exists in the + /// central truststore. + #[serde(rename = "central-truststore-reference")] + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub central_truststore_reference: Option, } /// Choice constraints for [`ServerAuthenticationCaCerts`]. @@ -284,6 +304,7 @@ pub mod tacacs_plus { /// Each inner slice is one case; at most one case may have fields set. pub const CHOICE_INLINE_OR_TRUSTSTORE: &[(&str, &[&str])] = &[ ("inline", &["inline-definition"]), + ("central-truststore", &["central-truststore-reference"]), ]; pub const CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY: bool = true; } @@ -605,6 +626,23 @@ pub mod keystore { pub const CHOICE_PRIVATE_KEY_TYPE_MANDATORY: bool = true; } + /// A reference to a specific certificate associated with + /// an asymmetric key stored in the central keystore. + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "kebab-case")] + pub struct EndEntityCertWithKeyCentralKeystoreReference { + /// A reference to an asymmetric key in the keystore. + #[serde(rename = "asymmetric-key")] + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub asymmetric_key: Option, + /// A reference to a specific certificate of the + /// asymmetric key in the keystore. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate: Option, + } + /// A container to hold the local key definition. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] diff --git a/libraries/tacacsrs_config/src/lib.rs b/libraries/tacacsrs_config/src/lib.rs index 2e8ecf9..73c6374 100644 --- a/libraries/tacacsrs_config/src/lib.rs +++ b/libraries/tacacsrs_config/src/lib.rs @@ -1,4 +1,5 @@ pub mod generated; +mod central_references; pub mod extensions; pub mod builders; pub mod validation; @@ -8,6 +9,10 @@ pub(crate) mod serde_helpers; mod statistics; pub use enumeration::{enumerate_server, enumerate_servers, validate_credential_references}; +pub use central_references::{ + CentralCredentialReference, CentralCredentialSlot, CentralCredentialUsage, + EnumerationRequiredError, UnexpandedCredentialField, inspect_central_references, +}; pub use validation::{ValidationOptions, ValidationRelaxation}; // Re-export key types from generated module for convenience @@ -89,6 +94,10 @@ pub mod pipeline { /// Runtime projection API used by networking/client code. pub mod runtime { + pub use crate::central_references::{ + CentralCredentialReference, CentralCredentialSlot, CentralCredentialUsage, + EnumerationRequiredError, UnexpandedCredentialField, inspect_central_references, + }; pub use crate::enumeration::{enumerate_server, enumerate_servers}; pub use crate::builders::{TacacsPlusBuilder, TacacsPlusServerBuilder}; pub use crate::extensions::TacacsPlusServerExt; diff --git a/libraries/tacacsrs_config/src/validation.rs b/libraries/tacacsrs_config/src/validation.rs index 299c5fe..ebd3cae 100644 --- a/libraries/tacacsrs_config/src/validation.rs +++ b/libraries/tacacsrs_config/src/validation.rs @@ -1,8 +1,9 @@ use std::collections::HashSet; use crate::generated::tacacs_plus::{ - ClientCredentials, ClientIdentityCertificate, ServerAuthenticationCaCerts, TacacsPlus, - TacacsPlusServer, Tls13Epsk, TlsClientClientIdentity, TlsClientServerAuthentication, + ClientCredentials, ClientIdentityCertificate, ServerAuthenticationCaCerts, ServerCredentials, + TacacsPlus, TacacsPlusServer, Tls13Epsk, TlsClientClientIdentity, + TlsClientServerAuthentication, }; // --------------------------------------------------------------------------- @@ -148,6 +149,9 @@ pub fn validate_config_with_options( validate_client_credentials(credentials)?; reject_unsupported_credentials_features(&credentials.id, credentials)?; } + for credentials in &config.server_credentials { + validate_server_credentials(credentials)?; + } crate::enumeration::validate_credential_references(config)?; @@ -241,23 +245,11 @@ fn validate_client_identity( )?; if let Some(ref certificate) = client_identity.certificate { - validate_choice( - &server.name, - "client-identity/certificate", - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - &[certificate.inline_definition.is_some()], - )?; + validate_certificate_choice(&server.name, "client-identity/certificate", certificate)?; } if let Some(ref tls13_epsk) = client_identity.tls13_epsk { - validate_choice( - &server.name, - "client-identity/tls13-epsk", - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - &[tls13_epsk.inline_definition.is_some()], - )?; + validate_epsk_choice(&server.name, "client-identity/tls13-epsk", tls13_epsk)?; } Ok(()) @@ -284,23 +276,11 @@ fn validate_server_authentication( )?; if let Some(ref ca_certs) = server_authentication.ca_certs { - validate_choice( - &server.name, - "server-authentication/ca-certs", - ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE, - ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY, - &[ca_certs.inline_definition.is_some()], - )?; + validate_trust_choice(&server.name, "server-authentication/ca-certs", ca_certs)?; } if let Some(ref ee_certs) = server_authentication.ee_certs { - validate_choice( - &server.name, - "server-authentication/ee-certs", - ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE, - ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY, - &[ee_certs.inline_definition.is_some()], - )?; + validate_trust_choice(&server.name, "server-authentication/ee-certs", ee_certs)?; } Ok(()) @@ -332,28 +312,77 @@ fn validate_client_credentials(credentials: &ClientCredentials) -> anyhow::Resul )?; if let Some(ref certificate) = credentials.certificate { - validate_choice( + validate_certificate_choice( &credentials.id, "client-credentials/certificate", - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - &[certificate.inline_definition.is_some()], + certificate, )?; } if let Some(ref tls13_epsk) = credentials.tls13_epsk { - validate_choice( - &credentials.id, - "client-credentials/tls13-epsk", - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - &[tls13_epsk.inline_definition.is_some()], - )?; + validate_epsk_choice(&credentials.id, "client-credentials/tls13-epsk", tls13_epsk)?; } Ok(()) } +fn validate_server_credentials(credentials: &ServerCredentials) -> anyhow::Result<()> { + if let Some(ref ca_certs) = credentials.ca_certs { + validate_trust_choice(&credentials.id, "server-credentials/ca-certs", ca_certs)?; + } + if let Some(ref ee_certs) = credentials.ee_certs { + validate_trust_choice(&credentials.id, "server-credentials/ee-certs", ee_certs)?; + } + Ok(()) +} + +fn validate_certificate_choice( + context: &str, + field_path: &str, + certificate: &ClientIdentityCertificate, +) -> anyhow::Result<()> { + validate_choice( + context, + field_path, + ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, + ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, + &[ + certificate.inline_definition.is_some(), + certificate.central_keystore_reference.is_some(), + ], + ) +} + +fn validate_epsk_choice(context: &str, field_path: &str, epsk: &Tls13Epsk) -> anyhow::Result<()> { + validate_choice( + context, + field_path, + Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, + Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, + &[ + epsk.inline_definition.is_some(), + epsk.central_keystore_reference.is_some(), + ], + ) +} + +fn validate_trust_choice( + context: &str, + field_path: &str, + certificates: &ServerAuthenticationCaCerts, +) -> anyhow::Result<()> { + validate_choice( + context, + field_path, + ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE, + ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY, + &[ + certificates.inline_definition.is_some(), + certificates.central_truststore_reference.is_some(), + ], + ) +} + fn validate_choice( server_name: &str, field_path: &str, @@ -396,7 +425,9 @@ fn reject_unsupported_inline_features( ci: &TlsClientClientIdentity, ) -> anyhow::Result<()> { if let Some(ref epsk) = ci.tls13_epsk { - reject_unsupported_epsk_derivation(context, epsk)?; + if epsk.inline_definition.is_some() { + reject_unsupported_epsk_derivation(context, epsk)?; + } } Ok(()) } @@ -407,7 +438,9 @@ fn reject_unsupported_credentials_features( creds: &ClientCredentials, ) -> anyhow::Result<()> { if let Some(ref epsk) = creds.tls13_epsk { - reject_unsupported_epsk_derivation(context, epsk)?; + if epsk.inline_definition.is_some() { + reject_unsupported_epsk_derivation(context, epsk)?; + } } Ok(()) } diff --git a/libraries/tacacsrs_config/tests/central_references.rs b/libraries/tacacsrs_config/tests/central_references.rs new file mode 100644 index 0000000..951491e --- /dev/null +++ b/libraries/tacacsrs_config/tests/central_references.rs @@ -0,0 +1,187 @@ +use tacacsrs_config::{ + CentralCredentialReference, CentralCredentialUsage, UnexpandedCredentialField, + enumerate_servers, inspect_central_references, parse_yang_json, pipeline, +}; + +#[test] +fn inspection_emits_deterministic_typed_slots_without_interpreting_references() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [ + { + "name": "certificate-and-trust", + "server-type": "accounting", + "address": "10.0.4.1", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "../outside SONiC grammar", + "certificate": "certificate with spaces" + } + } + }, + "server-authentication": { + "ca-certs": { + "central-truststore-reference": "CA/bag with spaces" + }, + "ee-certs": { + "central-truststore-reference": "EE:bag:opaque" + } + } + }, + { + "name": "epsk", + "server-type": "accounting", + "address": "10.0.4.2", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "symmetric key opaque value", + "external-identity": "client@example.test" + } + } + } + ] + } + }"#, + ) + .expect("central references should parse"); + + let first = + inspect_central_references(&config.server[0]).expect("direct server is inspectable"); + assert_eq!( + first + .iter() + .map(tacacsrs_config::CentralCredentialSlot::usage) + .collect::>(), + [ + CentralCredentialUsage::ClientCertificateWithKey, + CentralCredentialUsage::ServerCaCertificateBag, + CentralCredentialUsage::ServerEeCertificateBag, + ] + ); + assert_eq!(first[0].server_name(), "certificate-and-trust"); + assert_eq!( + first[0].reference(), + CentralCredentialReference::CertificateWithKey { + asymmetric_key: Some("../outside SONiC grammar"), + certificate: Some("certificate with spaces"), + } + ); + assert_eq!( + first[1].reference(), + CentralCredentialReference::CertificateBag("CA/bag with spaces") + ); + assert_eq!(first[2].reference(), CentralCredentialReference::CertificateBag("EE:bag:opaque")); + + let second = inspect_central_references(&config.server[1]).expect("EPSK server is inspectable"); + assert_eq!(second.len(), 1); + assert_eq!(second[0].usage(), CentralCredentialUsage::ClientTls13Epsk); + assert_eq!( + second[0].reference(), + CentralCredentialReference::SymmetricKey("symmetric key opaque value") + ); +} + +#[test] +fn inspection_requires_config_local_bundles_to_be_enumerated_first() { + let root = pipeline::parse_root_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [{ + "id": "do-not-disclose-this-local-id", + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "central-key" + } + } + }], + "server": [{ + "name": "bundled-server", + "server-type": "accounting", + "address": "10.0.4.3", + "port": 49, + "client-identity": { + "credentials-reference": "do-not-disclose-this-local-id" + } + }] + } + }"#, + ) + .expect("raw config should parse"); + + let error = inspect_central_references(&root.tacacs_plus.server[0]) + .expect_err("raw local bundle reference must require enumeration"); + assert_eq!(error.server_name(), "bundled-server"); + assert_eq!(error.field(), UnexpandedCredentialField::ClientIdentity); + assert!(error.to_string().contains("enumerate_server")); + assert!(!error.to_string().contains("do-not-disclose-this-local-id")); + + let enumerated = enumerate_servers(&root.tacacs_plus).expect("bundle should enumerate"); + let slots = + inspect_central_references(&enumerated[0]).expect("enumerated server is inspectable"); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].usage(), CentralCredentialUsage::ClientCertificateWithKey); +} + +#[test] +fn inspection_preserves_structurally_incomplete_central_certificate_for_planning() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "incomplete-central", + "server-type": "accounting", + "address": "10.0.4.4", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": {} + } + } + }] + } + }"#, + ) + .expect("generated model permits an empty central container"); + + let slots = inspect_central_references(&config.server[0]).expect("server is inspectable"); + assert_eq!( + slots[0].reference(), + CentralCredentialReference::CertificateWithKey { + asymmetric_key: None, + certificate: None, + } + ); +} + +#[test] +fn slot_debug_output_redacts_all_opaque_reference_values() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "debug-server", + "server-type": "accounting", + "address": "10.0.4.5", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "never-render-this-reference", + "external-identity": "client@example.test" + } + } + }] + } + }"#, + ) + .expect("central EPSK should parse"); + + let slots = inspect_central_references(&config.server[0]).expect("server is inspectable"); + let debug = format!("{:?} {:?}", slots[0], slots[0].reference()); + assert!(debug.contains("")); + assert!(!debug.contains("never-render-this-reference")); + assert!(!debug.contains("10.0.4.5")); +} diff --git a/libraries/tacacsrs_config/tests/central_roundtrip.rs b/libraries/tacacsrs_config/tests/central_roundtrip.rs new file mode 100644 index 0000000..42ece0b --- /dev/null +++ b/libraries/tacacsrs_config/tests/central_roundtrip.rs @@ -0,0 +1,190 @@ +use serde_json::{Value, json}; +use tacacsrs_config::{YangConfigRoot, parse_yang_json}; + +fn round_trip(input: &str) -> Value { + let parsed = parse_yang_json(input).expect("initial central config should parse"); + let serialized = serde_json::to_value(YangConfigRoot { + tacacs_plus: parsed, + }) + .expect("central config should serialize"); + let reparsed = parse_yang_json( + &serde_json::to_string(&serialized).expect("serialized config should be JSON"), + ) + .expect("serialized central config should reparse"); + let reserialized = serde_json::to_value(YangConfigRoot { + tacacs_plus: reparsed, + }) + .expect("reparsed central config should serialize"); + assert_eq!(reserialized, serialized); + serialized +} + +#[test] +fn direct_central_references_round_trip_with_exact_values() { + let value = round_trip( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [ + { + "name": "certificate-and-trust", + "server-type": "accounting", + "address": "192.0.2.21", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "../opaque asymmetric key", + "certificate": "opaque certificate value" + } + } + }, + "server-authentication": { + "ca-certs": { + "central-truststore-reference": "opaque CA bag/value" + }, + "ee-certs": { + "central-truststore-reference": "opaque EE bag value" + } + } + }, + { + "name": "epsk", + "server-type": "accounting", + "address": "192.0.2.22", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "opaque symmetric key/value", + "external-identity": "client@example.test", + "hash": "sha-384", + "context": "opaque provider context", + "target-protocol": 7, + "target-kdf": 9, + "tacacsrs:psk-dhe-ke-groups": ["secp384r1", "x25519"] + } + } + } + ] + } + }"#, + ); + let servers = value["ietf-system-tacacs-plus:tacacs-plus"]["server"] + .as_array() + .expect("serialized servers"); + + assert_eq!( + servers[0]["client-identity"]["certificate"]["central-keystore-reference"], + json!({ + "asymmetric-key": "../opaque asymmetric key", + "certificate": "opaque certificate value", + }) + ); + assert_eq!( + servers[0]["server-authentication"]["ca-certs"]["central-truststore-reference"], + "opaque CA bag/value" + ); + assert_eq!( + servers[0]["server-authentication"]["ee-certs"]["central-truststore-reference"], + "opaque EE bag value" + ); + assert_eq!( + servers[1]["client-identity"]["tls13-epsk"], + json!({ + "central-keystore-reference": "opaque symmetric key/value", + "external-identity": "client@example.test", + "hash": "sha-384", + "context": "opaque provider context", + "target-protocol": 7, + "target-kdf": 9, + "tacacsrs:psk-dhe-ke-groups": ["secp384r1", "x25519"], + }) + ); +} + +#[test] +fn bundled_central_references_round_trip_with_exact_values() { + let value = round_trip( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [ + { + "id": "certificate-bundle", + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "bundle asymmetric key", + "certificate": "bundle certificate" + } + } + }, + { + "id": "epsk-bundle", + "tls13-epsk": { + "central-keystore-reference": "bundle symmetric key", + "external-identity": "bundle@example.test", + "hash": "sha-384", + "context": "bundle context", + "target-protocol": 11, + "target-kdf": 13, + "tacacsrs:psk-dhe-ke-groups": ["x25519"] + } + } + ], + "server-credentials": [{ + "id": "trust-bundle", + "ca-certs": { + "central-truststore-reference": "bundle CA bag" + }, + "ee-certs": { + "central-truststore-reference": "bundle EE bag" + } + }], + "server": [ + { + "name": "certificate-server", + "server-type": "accounting", + "address": "192.0.2.23", + "port": 49, + "client-identity": {"credentials-reference": "certificate-bundle"}, + "server-authentication": {"credentials-reference": "trust-bundle"} + }, + { + "name": "epsk-server", + "server-type": "accounting", + "address": "192.0.2.24", + "port": 49, + "client-identity": {"credentials-reference": "epsk-bundle"} + } + ] + } + }"#, + ); + let config = &value["ietf-system-tacacs-plus:tacacs-plus"]; + let clients = config["client-credentials"] + .as_array() + .expect("client bundles"); + let servers = config["server-credentials"] + .as_array() + .expect("server bundles"); + + assert_eq!( + clients[0]["certificate"]["central-keystore-reference"], + json!({ + "asymmetric-key": "bundle asymmetric key", + "certificate": "bundle certificate", + }) + ); + assert_eq!( + clients[1]["tls13-epsk"], + json!({ + "central-keystore-reference": "bundle symmetric key", + "external-identity": "bundle@example.test", + "hash": "sha-384", + "context": "bundle context", + "target-protocol": 11, + "target-kdf": 13, + "tacacsrs:psk-dhe-ke-groups": ["x25519"], + }) + ); + assert_eq!(servers[0]["ca-certs"]["central-truststore-reference"], "bundle CA bag"); + assert_eq!(servers[0]["ee-certs"]["central-truststore-reference"], "bundle EE bag"); +} diff --git a/libraries/tacacsrs_config/tests/central_validation.rs b/libraries/tacacsrs_config/tests/central_validation.rs new file mode 100644 index 0000000..90747a4 --- /dev/null +++ b/libraries/tacacsrs_config/tests/central_validation.rs @@ -0,0 +1,371 @@ +use serde_json::{Value, json}; +use tacacsrs_config::parse_yang_json; + +fn root( + servers: Vec, + client_credentials: Vec, + server_credentials: Vec, +) -> Value { + json!({ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": Value::Array(client_credentials), + "server-credentials": Value::Array(server_credentials), + "server": Value::Array(servers), + } + }) +} + +fn plain_server(name: &str, address: &str) -> Value { + json!({ + "name": name, + "server-type": "accounting", + "address": address, + "port": 49, + "shared-secret": "test-secret", + }) +} + +fn assert_validation_error(value: &Value, expected_path: &str, expected_rule: &str) { + let error = parse_yang_json(&value.to_string()).expect_err("configuration should be rejected"); + let message = error.to_string(); + assert!(message.contains(expected_path), "missing path {expected_path}: {message}"); + assert!(message.contains(expected_rule), "missing rule {expected_rule}: {message}"); +} + +fn inline_certificate() -> Value { + json!({ + "cleartext-private-key": "a2V5", + "cert-data": "Y2VydA==", + }) +} + +fn central_certificate() -> Value { + json!({ + "asymmetric-key": "opaque-asymmetric-key", + "certificate": "opaque-certificate", + }) +} + +fn inline_epsk() -> Value { + json!({"cleartext-symmetric-key": "c2VjcmV0"}) +} + +fn inline_trust() -> Value { + json!({ + "certificate": [{ + "name": "trust-anchor", + "cert-data": "Y2VydA==", + }] + }) +} + +#[test] +fn central_only_choices_validate_for_direct_and_bundle_usages() { + let config = root( + vec![ + json!({ + "name": "direct-certificate", + "server-type": "accounting", + "address": "10.0.1.1", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": central_certificate(), + } + } + }), + json!({ + "name": "direct-epsk", + "server-type": "accounting", + "address": "10.0.1.2", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "opaque-symmetric-key", + "external-identity": "client@example.test", + "hash": "sha-384", + "context": "provider-context", + "target-protocol": 7, + "target-kdf": 9, + "tacacsrs:psk-dhe-ke-groups": ["secp384r1"], + } + } + }), + json!({ + "name": "direct-trust", + "server-type": "accounting", + "address": "10.0.1.3", + "port": 49, + "server-authentication": { + "ca-certs": { + "central-truststore-reference": "opaque-ca-bag", + }, + "ee-certs": { + "central-truststore-reference": "opaque-ee-bag", + } + } + }), + json!({ + "name": "bundled", + "server-type": "accounting", + "address": "10.0.1.4", + "port": 49, + "client-identity": {"credentials-reference": "client-certificate-bundle"}, + "server-authentication": {"credentials-reference": "server-trust-bundle"}, + }), + ], + vec![ + json!({ + "id": "client-certificate-bundle", + "certificate": { + "central-keystore-reference": central_certificate(), + } + }), + json!({ + "id": "client-epsk-bundle", + "tls13-epsk": { + "central-keystore-reference": "opaque-bundle-symmetric-key", + "external-identity": "bundle@example.test", + } + }), + ], + vec![json!({ + "id": "server-trust-bundle", + "ca-certs": {"central-truststore-reference": "opaque-bundle-ca"}, + "ee-certs": {"central-truststore-reference": "opaque-bundle-ee"}, + })], + ); + + parse_yang_json(&config.to_string()).expect("all central-only usages should validate"); +} + +#[test] +#[allow(clippy::too_many_lines)] // The table intentionally keeps direct and bundle cases together. +fn inline_and_central_cases_are_rejected_for_every_usage() { + let dual_certificate = json!({ + "inline-definition": inline_certificate(), + "central-keystore-reference": central_certificate(), + }); + let dual_epsk = json!({ + "inline-definition": inline_epsk(), + "central-keystore-reference": "opaque-symmetric-key", + "external-identity": "client@example.test", + }); + let dual_trust = json!({ + "inline-definition": inline_trust(), + "central-truststore-reference": "opaque-bag", + }); + + let cases = [ + ( + root( + vec![json!({ + "name": "direct-certificate", + "server-type": "accounting", + "address": "10.0.2.1", + "port": 49, + "client-identity": {"certificate": dual_certificate}, + })], + vec![], + vec![], + ), + "client-identity/certificate", + "inline, central-keystore", + ), + ( + root( + vec![json!({ + "name": "direct-epsk", + "server-type": "accounting", + "address": "10.0.2.2", + "port": 49, + "client-identity": {"tls13-epsk": dual_epsk}, + })], + vec![], + vec![], + ), + "client-identity/tls13-epsk", + "inline, central-keystore", + ), + ( + root( + vec![json!({ + "name": "direct-ca", + "server-type": "accounting", + "address": "10.0.2.3", + "port": 49, + "server-authentication": {"ca-certs": dual_trust}, + })], + vec![], + vec![], + ), + "server-authentication/ca-certs", + "inline, central-truststore", + ), + ( + root( + vec![json!({ + "name": "direct-ee", + "server-type": "accounting", + "address": "10.0.2.4", + "port": 49, + "server-authentication": {"ee-certs": dual_trust}, + })], + vec![], + vec![], + ), + "server-authentication/ee-certs", + "inline, central-truststore", + ), + ( + root( + vec![plain_server("client-certificate-bundle", "10.0.2.5")], + vec![json!({"id": "client-certificate", "certificate": dual_certificate})], + vec![], + ), + "client-credentials/certificate", + "inline, central-keystore", + ), + ( + root( + vec![plain_server("client-epsk-bundle", "10.0.2.6")], + vec![json!({"id": "client-epsk", "tls13-epsk": dual_epsk})], + vec![], + ), + "client-credentials/tls13-epsk", + "inline, central-keystore", + ), + ( + root( + vec![plain_server("server-ca-bundle", "10.0.2.7")], + vec![], + vec![json!({"id": "server-ca", "ca-certs": dual_trust})], + ), + "server-credentials/ca-certs", + "inline, central-truststore", + ), + ( + root( + vec![plain_server("server-ee-bundle", "10.0.2.8")], + vec![], + vec![json!({"id": "server-ee", "ee-certs": dual_trust})], + ), + "server-credentials/ee-certs", + "inline, central-truststore", + ), + ]; + + for (config, expected_path, expected_cases) in cases { + assert_validation_error(&config, expected_path, expected_cases); + } +} + +#[test] +#[allow(clippy::too_many_lines)] // The table intentionally keeps direct and bundle cases together. +fn mandatory_choices_are_enforced_for_every_direct_and_bundle_usage() { + let cases = [ + ( + root( + vec![json!({ + "name": "direct-certificate", + "server-type": "accounting", + "address": "10.0.3.1", + "port": 49, + "client-identity": {"certificate": {}}, + })], + vec![], + vec![], + ), + "client-identity/certificate", + "requires one of", + ), + ( + root( + vec![json!({ + "name": "direct-epsk", + "server-type": "accounting", + "address": "10.0.3.2", + "port": 49, + "client-identity": {"tls13-epsk": {"external-identity": "client"}}, + })], + vec![], + vec![], + ), + "client-identity/tls13-epsk", + "requires one of", + ), + ( + root( + vec![json!({ + "name": "direct-ca", + "server-type": "accounting", + "address": "10.0.3.3", + "port": 49, + "server-authentication": {"ca-certs": {}}, + })], + vec![], + vec![], + ), + "server-authentication/ca-certs", + "requires one of", + ), + ( + root( + vec![json!({ + "name": "direct-ee", + "server-type": "accounting", + "address": "10.0.3.4", + "port": 49, + "server-authentication": {"ee-certs": {}}, + })], + vec![], + vec![], + ), + "server-authentication/ee-certs", + "requires one of", + ), + ( + root( + vec![plain_server("client-certificate-bundle", "10.0.3.5")], + vec![json!({"id": "client-certificate", "certificate": {}})], + vec![], + ), + "client-credentials/certificate", + "requires one of", + ), + ( + root( + vec![plain_server("client-epsk-bundle", "10.0.3.6")], + vec![json!({ + "id": "client-epsk", + "tls13-epsk": {"external-identity": "client"}, + })], + vec![], + ), + "client-credentials/tls13-epsk", + "requires one of", + ), + ( + root( + vec![plain_server("server-ca-bundle", "10.0.3.7")], + vec![], + vec![json!({"id": "server-ca", "ca-certs": {}})], + ), + "server-credentials/ca-certs", + "requires one of", + ), + ( + root( + vec![plain_server("server-ee-bundle", "10.0.3.8")], + vec![], + vec![json!({"id": "server-ee", "ee-certs": {}})], + ), + "server-credentials/ee-certs", + "requires one of", + ), + ]; + + for (config, expected_path, expected_rule) in cases { + assert_validation_error(&config, expected_path, expected_rule); + } +} diff --git a/libraries/tacacsrs_config/tests/enumeration_integration.rs b/libraries/tacacsrs_config/tests/enumeration_integration.rs index d54f2a2..5dd3be6 100644 --- a/libraries/tacacsrs_config/tests/enumeration_integration.rs +++ b/libraries/tacacsrs_config/tests/enumeration_integration.rs @@ -84,6 +84,156 @@ fn enumerate_servers_inlines_credential_bundles() { assert_eq!(inline_certs.certificate[0].cert_data, b"test-cert"); } +#[test] +#[allow(clippy::too_many_lines)] // One snapshot assertion covers every preserved nested field. +fn enumerate_servers_preserves_all_nested_central_references_and_metadata() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [ + { + "id": "central-cert", + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "opaque/asymmetric key", + "certificate": "opaque certificate" + } + } + }, + { + "id": "central-epsk", + "tls13-epsk": { + "central-keystore-reference": "opaque symmetric key", + "external-identity": "client@example.test", + "hash": "sha-384", + "context": "provider context", + "target-protocol": 7, + "target-kdf": 9, + "tacacsrs:psk-dhe-ke-groups": ["secp384r1", "x25519"] + } + } + ], + "server-credentials": [ + { + "id": "central-trust", + "ca-certs": { + "central-truststore-reference": "opaque CA bag" + }, + "ee-certs": { + "central-truststore-reference": "opaque EE bag" + } + } + ], + "server": [ + { + "name": "certificate-server", + "server-type": "accounting", + "address": "10.0.0.10", + "port": 49, + "client-identity": {"credentials-reference": "central-cert"}, + "server-authentication": {"credentials-reference": "central-trust"} + }, + { + "name": "epsk-server", + "server-type": "accounting", + "address": "10.0.0.11", + "port": 49, + "client-identity": {"credentials-reference": "central-epsk"} + } + ] + } + }"#, + ) + .expect("central bundle config should parse"); + + let enumerated = enumerate_servers(&config).expect("enumeration should succeed"); + let certificate_identity = enumerated[0] + .client_identity + .as_ref() + .expect("certificate identity"); + assert!(certificate_identity.credentials_reference.is_none()); + let certificate_reference = certificate_identity + .certificate + .as_ref() + .and_then(|certificate| certificate.central_keystore_reference.as_ref()) + .expect("central certificate reference"); + assert_eq!(certificate_reference.asymmetric_key.as_deref(), Some("opaque/asymmetric key")); + assert_eq!(certificate_reference.certificate.as_deref(), Some("opaque certificate")); + + let server_authentication = enumerated[0] + .server_authentication + .as_ref() + .expect("server authentication"); + assert!(server_authentication.credentials_reference.is_none()); + assert_eq!( + server_authentication + .ca_certs + .as_ref() + .and_then(|certificates| certificates.central_truststore_reference.as_deref()), + Some("opaque CA bag") + ); + assert_eq!( + server_authentication + .ee_certs + .as_ref() + .and_then(|certificates| certificates.central_truststore_reference.as_deref()), + Some("opaque EE bag") + ); + + let epsk_identity = enumerated[1] + .client_identity + .as_ref() + .expect("EPSK identity"); + assert!(epsk_identity.credentials_reference.is_none()); + let epsk = epsk_identity.tls13_epsk.as_ref().expect("central EPSK"); + assert_eq!(epsk.central_keystore_reference.as_deref(), Some("opaque symmetric key")); + assert_eq!(epsk.external_identity, "client@example.test"); + assert_eq!(epsk.hash, tacacsrs_config::EpskSupportedHash::Sha384); + assert_eq!(epsk.context.as_deref(), Some("provider context")); + assert_eq!(epsk.target_protocol, Some(7)); + assert_eq!(epsk.target_kdf, Some(9)); + assert_eq!( + epsk.psk_dhe_ke_groups, + [ + tacacsrs_config::PskDheKeSupportedGroup::Secp384r1, + tacacsrs_config::PskDheKeSupportedGroup::X25519, + ] + ); +} + +#[test] +fn central_references_are_not_validated_as_local_bundle_ids() { + let root = pipeline::parse_root_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "central-direct", + "server-type": "accounting", + "address": "10.0.0.12", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "missing-local-bundle", + "certificate": "also-not-local" + } + } + }, + "server-authentication": { + "ca-certs": { + "central-truststore-reference": "not-a-local-server-bundle" + } + } + }] + } + }"#, + ) + .expect("raw central references should parse"); + + validate_credential_references(&root.tacacs_plus) + .expect("external central references are not config-local bundle IDs"); +} + #[test] fn validate_credential_references_collects_missing_client_bundle_ref() { let root = pipeline::parse_root_json( diff --git a/libraries/tacacsrs_config/tests/generated_types.rs b/libraries/tacacsrs_config/tests/generated_types.rs index f39a175..9e5758d 100644 --- a/libraries/tacacsrs_config/tests/generated_types.rs +++ b/libraries/tacacsrs_config/tests/generated_types.rs @@ -2,6 +2,108 @@ use tacacsrs_config::crypto_types::{PrivateKeyFormat, PublicKeyFormat, Symmetric use tacacsrs_config::{parse_yang_json, PskDheKeSupportedGroup, TacacsPlusServerType}; use tacacsrs_config::{TacacsPlusBuilder, TacacsPlusServerBuilder}; +const _: () = + assert!(tacacsrs_config::ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY); +const _: () = assert!(tacacsrs_config::Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY); +const _: () = + assert!(tacacsrs_config::ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY); + +#[test] +fn generated_central_certificate_shape_and_choice_metadata() { + let certificate = tacacsrs_config::ClientIdentityCertificate { + inline_definition: None, + central_keystore_reference: Some( + tacacsrs_config::keystore::EndEntityCertWithKeyCentralKeystoreReference { + asymmetric_key: Some("opaque-asymmetric-key".to_owned()), + certificate: Some("opaque-certificate".to_owned()), + }, + ), + }; + let bundle = tacacsrs_config::ClientCredentials { + id: "bundle".to_owned(), + certificate: Some(certificate), + tls13_epsk: None, + }; + + assert_eq!( + tacacsrs_config::ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, + &[ + ("inline", &["inline-definition"][..]), + ("central-keystore", &["central-keystore-reference"][..]), + ], + ); + let serialized = serde_json::to_value(bundle).expect("central certificate should serialize"); + assert_eq!( + serialized["certificate"]["central-keystore-reference"]["asymmetric-key"], + "opaque-asymmetric-key", + ); + assert_eq!( + serialized["certificate"]["central-keystore-reference"]["certificate"], + "opaque-certificate", + ); +} + +#[test] +fn generated_central_epsk_shape_preserves_protocol_metadata() { + let epsk = tacacsrs_config::Tls13Epsk { + inline_definition: None, + central_keystore_reference: Some("opaque-symmetric-key".to_owned()), + external_identity: "client@example.test".to_owned(), + hash: tacacsrs_config::EpskSupportedHash::Sha384, + context: Some("role-context".to_owned()), + target_protocol: Some(7), + target_kdf: Some(9), + psk_dhe_ke_groups: vec![PskDheKeSupportedGroup::Secp384r1], + }; + let bundle = tacacsrs_config::ClientCredentials { + id: "bundle".to_owned(), + certificate: None, + tls13_epsk: Some(epsk), + }; + + assert_eq!( + tacacsrs_config::Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, + &[ + ("inline", &["inline-definition"][..]), + ("central-keystore", &["central-keystore-reference"][..]), + ], + ); + let serialized = serde_json::to_value(bundle).expect("central EPSK should serialize"); + let epsk = &serialized["tls13-epsk"]; + assert_eq!(epsk["central-keystore-reference"], "opaque-symmetric-key"); + assert_eq!(epsk["external-identity"], "client@example.test"); + assert_eq!(epsk["hash"], "sha-384"); + assert_eq!(epsk["context"], "role-context"); + assert_eq!(epsk["target-protocol"], 7); + assert_eq!(epsk["target-kdf"], 9); + assert_eq!(epsk["tacacsrs:psk-dhe-ke-groups"][0], "secp384r1"); +} + +#[test] +fn generated_central_trust_shape_is_shared_by_direct_and_bundle_ca_ee_fields() { + let trust = tacacsrs_config::ServerAuthenticationCaCerts { + inline_definition: None, + central_truststore_reference: Some("opaque-certificate-bag".to_owned()), + }; + let bundle = tacacsrs_config::ServerCredentials { + id: "bundle".to_owned(), + ca_certs: Some(trust.clone()), + ee_certs: Some(trust), + tls13_epsks: None, + }; + + assert_eq!( + tacacsrs_config::ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE, + &[ + ("inline", &["inline-definition"][..]), + ("central-truststore", &["central-truststore-reference"][..]), + ], + ); + let serialized = serde_json::to_value(bundle).expect("central trust should serialize"); + assert_eq!(serialized["ca-certs"]["central-truststore-reference"], "opaque-certificate-bag",); + assert_eq!(serialized["ee-certs"]["central-truststore-reference"], "opaque-certificate-bag",); +} + // --------------------------------------------------------------------------- // PublicKeyFormat identity enum // --------------------------------------------------------------------------- diff --git a/libraries/tacacsrs_config/tests/parse_validation.rs b/libraries/tacacsrs_config/tests/parse_validation.rs index 76580b8..a619824 100644 --- a/libraries/tacacsrs_config/tests/parse_validation.rs +++ b/libraries/tacacsrs_config/tests/parse_validation.rs @@ -439,7 +439,7 @@ fn reject_missing_inline_or_keystore_choice() { let err = parse_yang_json(json).unwrap_err(); assert!( err.to_string() - .contains("client-identity/certificate requires one of [inline]"), + .contains("client-identity/certificate requires one of [inline, central-keystore]"), "unexpected error: {err}", ); } @@ -464,8 +464,9 @@ fn reject_missing_inline_or_truststore_choice() { let err = parse_yang_json(json).unwrap_err(); assert!( - err.to_string() - .contains("server-authentication/ca-certs requires one of [inline]"), + err.to_string().contains( + "server-authentication/ca-certs requires one of [inline, central-truststore]" + ), "unexpected error: {err}", ); } @@ -1245,39 +1246,37 @@ fn expected_client_identity_choice_mappings() -> [(&'static str, &'static [&'sta ( "ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY", &[ - r#" + r" validate_choice( - &server.name, - "client-identity/certificate", + context, + field_path, ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - "#, + ", r#" - validate_choice( + validate_certificate_choice( &credentials.id, "client-credentials/certificate", - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE, - ClientIdentityCertificate::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, "#, + r#"validate_certificate_choice(&server.name, "client-identity/certificate", certificate)"#, ], ), ( "Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY", &[ - r#" + r" validate_choice( - &server.name, - "client-identity/tls13-epsk", + context, + field_path, Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, - "#, + ", r#" - validate_choice( + validate_epsk_choice( &credentials.id, "client-credentials/tls13-epsk", - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE, - Tls13Epsk::CHOICE_INLINE_OR_KEYSTORE_MANDATORY, "#, + r#"validate_epsk_choice(&server.name, "client-identity/tls13-epsk", tls13_epsk)"#, ], ), ] @@ -1297,13 +1296,27 @@ fn expected_server_auth_choice_mappings() -> [(&'static str, &'static [&'static ), ( "ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY", - &[r#" + &[ + r" validate_choice( - &server.name, - "server-authentication/ca-certs", + context, + field_path, ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE, ServerAuthenticationCaCerts::CHOICE_INLINE_OR_TRUSTSTORE_MANDATORY, - "#], + ", + r#"validate_trust_choice(&server.name, "server-authentication/ca-certs", ca_certs)"#, + r#"validate_trust_choice(&server.name, "server-authentication/ee-certs", ee_certs)"#, + r#" + validate_trust_choice( + &credentials.id, + "server-credentials/ca-certs", + "#, + r#" + validate_trust_choice( + &credentials.id, + "server-credentials/ee-certs", + "#, + ], ), ] } @@ -1587,7 +1600,11 @@ fn reject_tls13_epsk_with_multiple_choice_sources() { }"#; let err = parse_yang_json(json).unwrap_err(); - assert_unknown_field_error(&err, "central-keystore-reference"); + assert!( + err.to_string() + .contains("client-identity/tls13-epsk allows only one of [inline, central-keystore]"), + "unexpected error: {err}", + ); } // --------------------------------------------------------------------------- diff --git a/libraries/tacacsrs_config/yang/expand_yang_tree.py b/libraries/tacacsrs_config/yang/expand_yang_tree.py index 6c4cee1..e32f978 100644 --- a/libraries/tacacsrs_config/yang/expand_yang_tree.py +++ b/libraries/tacacsrs_config/yang/expand_yang_tree.py @@ -36,9 +36,11 @@ import os import re import shutil +import stat import subprocess import sys import textwrap +from collections.abc import Callable from pathlib import Path from pyang import context @@ -47,12 +49,14 @@ from pyang import util YANG_MODELS_REPO = "https://github.com/YangModels/yang.git" +YANG_MODELS_COMMIT = "97ca2414920c0de09171327b84aa11395f79e284" TACACS_ROOT_MODULE = "ietf-system-tacacs-plus" TACACS_MODULE = "ietf-system-tacacs-plus@2026-03-31.yang" SCRIPT_DIR = Path(__file__).resolve().parent -CACHE_DIR = SCRIPT_DIR / ".yang-cache" +CACHE_ROOT = SCRIPT_DIR / ".yang-cache" +CACHE_DIR = CACHE_ROOT / sys.platform PLUGIN_DIR = SCRIPT_DIR / "plugins" LOCAL_YANG_DIR = SCRIPT_DIR / "modules" @@ -63,6 +67,20 @@ def _run(args: list[str], **kwargs) -> subprocess.CompletedProcess: return subprocess.run(args, check=True, capture_output=True, text=True, **kwargs) +def _remove_readonly( + function: Callable[[str], object], + path: str, + _exc_info: object, +) -> None: + os.chmod(path, stat.S_IWRITE) + function(path) + + +def clean_cache() -> None: + if CACHE_DIR.exists(): + shutil.rmtree(CACHE_DIR, onerror=_remove_readonly) + + def _local_yang_modules() -> list[Path]: if not LOCAL_YANG_DIR.exists(): return [] @@ -296,19 +314,52 @@ def write_output(content: str, output_path: Path | None) -> None: print(content) -def ensure_repo(url: str, name: str, sparse_paths: list[str] | None = None) -> Path: - """Clone a repo into the cache directory (shallow, optionally sparse).""" +def _verify_repo_revision(repo_path: Path, expected_commit: str) -> None: + actual_commit = _run(["git", "rev-parse", "HEAD"], cwd=str(repo_path)).stdout.strip() + if actual_commit != expected_commit: + raise RuntimeError( + f"cached repository {repo_path} is at {actual_commit}, expected {expected_commit}; " + "rerun with --clean to deliberately refresh it" + ) + + symbolic_head = subprocess.run( + ["git", "symbolic-ref", "-q", "HEAD"], + cwd=str(repo_path), + capture_output=True, + text=True, + ) + if symbolic_head.returncode == 0: + raise RuntimeError( + f"cached repository {repo_path} is attached to {symbolic_head.stdout.strip()}, " + "expected detached HEAD; rerun with --clean" + ) + if symbolic_head.returncode != 1: + raise RuntimeError(f"failed to inspect cached repository HEAD at {repo_path}") + + +def ensure_repo( + url: str, + name: str, + commit: str, + sparse_paths: list[str] | None = None, +) -> Path: + """Clone an exact revision into the cache and verify its detached HEAD.""" dest = CACHE_DIR / name if dest.exists(): + _verify_repo_revision(dest, commit) return dest CACHE_DIR.mkdir(parents=True, exist_ok=True) if sparse_paths: - _run(["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", url, str(dest)]) + _run(["git", "clone", "--filter=blob:none", "--sparse", "--no-checkout", url, str(dest)]) _run(["git", "sparse-checkout", "set"] + sparse_paths, cwd=str(dest)) else: - _run(["git", "clone", "--depth", "1", url, str(dest)]) + _run(["git", "clone", "--filter=blob:none", "--no-checkout", url, str(dest)]) + + _run(["git", "fetch", "--depth", "1", "origin", commit], cwd=str(dest)) + _run(["git", "checkout", "--detach", commit], cwd=str(dest)) + _verify_repo_revision(dest, commit) return dest @@ -370,12 +421,17 @@ def main() -> None: args = parser.parse_args() if args.clean and CACHE_DIR.exists(): - shutil.rmtree(CACHE_DIR) + clean_cache() print(f"Removed cache directory: {CACHE_DIR}", file=sys.stderr) # Clone required repos print("Fetching YANG modules (cached after first run)...", file=sys.stderr) - yang_models = ensure_repo(YANG_MODELS_REPO, "yang-models", sparse_paths=["standard/ietf/RFC"]) + yang_models = ensure_repo( + YANG_MODELS_REPO, + "yang-models", + YANG_MODELS_COMMIT, + sparse_paths=["standard/ietf/RFC"], + ) # Build search paths rfc_yang_dir = yang_models / "standard" / "ietf" / "RFC" diff --git a/libraries/tacacsrs_config/yang/expanded-tree.txt b/libraries/tacacsrs_config/yang/expanded-tree.txt index 2ac0690..f5e7eb5 100644 --- a/libraries/tacacsrs_config/yang/expanded-tree.txt +++ b/libraries/tacacsrs_config/yang/expanded-tree.txt @@ -9,45 +9,55 @@ module: ietf-system-tacacs-plus | | +--rw certificate | | +--rw (inline-or-keystore) | | +--:(inline) {inline-definitions-supported}? - | | +--rw inline-definition - | | +--rw public-key-format? identityref - | | +--rw public-key? binary - | | +--rw private-key-format? identityref - | | +--rw (private-key-type) - | | | +--:(cleartext-private-key) {cleartext-private-keys}? - | | | +--rw cleartext-private-key? binary - | | +--rw cert-data? end-entity-cert-cms + | | | +--rw inline-definition + | | | +--rw public-key-format? identityref + | | | +--rw public-key? binary + | | | +--rw private-key-format? identityref + | | | +--rw (private-key-type) + | | | | +--:(cleartext-private-key) {cleartext-private-keys}? + | | | | +--rw cleartext-private-key? binary + | | | +--rw cert-data? end-entity-cert-cms + | | +--:(central-keystore) {central-keystore-supported,asymmetric-keys}? + | | +--rw central-keystore-reference + | | +--rw asymmetric-key? ks:central-asymmetric-key-ref {central-keystore-supported,asymmetric-keys}? + | | +--rw certificate? -> /ks:keystore/asymmetric-keys/asymmetric-key[ks:name = current()/../asymmetric-key]/certificates/certificate/name | +--:(tls13-epsk) {tlsc:client-ident-tls13-epsk}? | +--rw tls13-epsk | +--rw (inline-or-keystore) | | +--:(inline) {inline-definitions-supported}? - | | +--rw inline-definition - | | +--rw key-format? identityref - | | +--rw (key-type) - | | +--:(cleartext-symmetric-key) - | | +--rw cleartext-symmetric-key? binary {cleartext-symmetric-keys}? - | +--rw external-identity string - | +--rw hash? tlscmn:epsk-supported-hash - | +--rw context? string - | +--rw target-protocol? uint16 - | +--rw target-kdf? uint16 - | +--rw trs:psk-dhe-ke-groups* psk-dhe-ke-supported-group {psk-dhe-ke-hello-params}? + | | | +--rw inline-definition + | | | +--rw key-format? identityref + | | | +--rw (key-type) + | | | +--:(cleartext-symmetric-key) + | | | +--rw cleartext-symmetric-key? binary {cleartext-symmetric-keys}? + | | +--:(central-keystore) {central-keystore-supported,symmetric-keys}? + | | +--rw central-keystore-reference? ks:central-symmetric-key-ref + | +--rw external-identity string + | +--rw hash? tlscmn:epsk-supported-hash + | +--rw context? string + | +--rw target-protocol? uint16 + | +--rw target-kdf? uint16 + | +--rw trs:psk-dhe-ke-groups* psk-dhe-ke-supported-group {psk-dhe-ke-hello-params}? +--rw server-credentials* [id] {credential-reference}? | +--rw id string | +--rw ca-certs! | | +--rw (inline-or-truststore) | | +--:(inline) {inline-definitions-supported}? - | | +--rw inline-definition - | | +--rw certificate* [name] - | | +--rw name string - | | +--rw cert-data trust-anchor-cert-cms + | | | +--rw inline-definition + | | | +--rw certificate* [name] + | | | +--rw name string + | | | +--rw cert-data trust-anchor-cert-cms + | | +--:(central-truststore) {central-truststore-supported,certificates}? + | | +--rw central-truststore-reference? ts:central-certificate-bag-ref | +--rw ee-certs! | | +--rw (inline-or-truststore) | | +--:(inline) {inline-definitions-supported}? - | | +--rw inline-definition - | | +--rw certificate* [name] - | | +--rw name string - | | +--rw cert-data trust-anchor-cert-cms + | | | +--rw inline-definition + | | | +--rw certificate* [name] + | | | +--rw name string + | | | +--rw cert-data trust-anchor-cert-cms + | | +--:(central-truststore) {central-truststore-supported,certificates}? + | | +--rw central-truststore-reference? ts:central-certificate-bag-ref | +--rw tls13-epsks? empty {tlsc:server-auth-tls13-epsk}? +--rw server* [name] +--rw name string @@ -68,29 +78,35 @@ module: ietf-system-tacacs-plus | | | | +--rw certificate | | | | +--rw (inline-or-keystore) | | | | +--:(inline) {inline-definitions-supported}? - | | | | +--rw inline-definition - | | | | +--rw public-key-format? identityref - | | | | +--rw public-key? binary - | | | | +--rw private-key-format? identityref - | | | | +--rw (private-key-type) - | | | | | +--:(cleartext-private-key) {cleartext-private-keys}? - | | | | | +--rw cleartext-private-key? binary - | | | | +--rw cert-data? end-entity-cert-cms + | | | | | +--rw inline-definition + | | | | | +--rw public-key-format? identityref + | | | | | +--rw public-key? binary + | | | | | +--rw private-key-format? identityref + | | | | | +--rw (private-key-type) + | | | | | | +--:(cleartext-private-key) {cleartext-private-keys}? + | | | | | | +--rw cleartext-private-key? binary + | | | | | +--rw cert-data? end-entity-cert-cms + | | | | +--:(central-keystore) {central-keystore-supported,asymmetric-keys}? + | | | | +--rw central-keystore-reference + | | | | +--rw asymmetric-key? ks:central-asymmetric-key-ref {central-keystore-supported,asymmetric-keys}? + | | | | +--rw certificate? -> /ks:keystore/asymmetric-keys/asymmetric-key[ks:name = current()/../asymmetric-key]/certificates/certificate/name | | | +--:(tls13-epsk) {tlsc:client-ident-tls13-epsk}? | | | +--rw tls13-epsk | | | +--rw (inline-or-keystore) | | | | +--:(inline) {inline-definitions-supported}? - | | | | +--rw inline-definition - | | | | +--rw key-format? identityref - | | | | +--rw (key-type) - | | | | +--:(cleartext-symmetric-key) - | | | | +--rw cleartext-symmetric-key? binary {cleartext-symmetric-keys}? - | | | +--rw external-identity string - | | | +--rw hash? tlscmn:epsk-supported-hash - | | | +--rw context? string - | | | +--rw target-protocol? uint16 - | | | +--rw target-kdf? uint16 - | | | +--rw trs:psk-dhe-ke-groups* psk-dhe-ke-supported-group {psk-dhe-ke-hello-params}? + | | | | | +--rw inline-definition + | | | | | +--rw key-format? identityref + | | | | | +--rw (key-type) + | | | | | +--:(cleartext-symmetric-key) + | | | | | +--rw cleartext-symmetric-key? binary {cleartext-symmetric-keys}? + | | | | +--:(central-keystore) {central-keystore-supported,symmetric-keys}? + | | | | +--rw central-keystore-reference? ks:central-symmetric-key-ref + | | | +--rw external-identity string + | | | +--rw hash? tlscmn:epsk-supported-hash + | | | +--rw context? string + | | | +--rw target-protocol? uint16 + | | | +--rw target-kdf? uint16 + | | | +--rw trs:psk-dhe-ke-groups* psk-dhe-ke-supported-group {psk-dhe-ke-hello-params}? | | +--rw server-authentication | | +--rw (ref-or-explicit)? | | +--:(ref) @@ -99,17 +115,21 @@ module: ietf-system-tacacs-plus | | +--rw ca-certs! | | | +--rw (inline-or-truststore) | | | +--:(inline) {inline-definitions-supported}? - | | | +--rw inline-definition - | | | +--rw certificate* [name] - | | | +--rw name string - | | | +--rw cert-data trust-anchor-cert-cms + | | | | +--rw inline-definition + | | | | +--rw certificate* [name] + | | | | +--rw name string + | | | | +--rw cert-data trust-anchor-cert-cms + | | | +--:(central-truststore) {central-truststore-supported,certificates}? + | | | +--rw central-truststore-reference? ts:central-certificate-bag-ref | | +--rw ee-certs! | | | +--rw (inline-or-truststore) | | | +--:(inline) {inline-definitions-supported}? - | | | +--rw inline-definition - | | | +--rw certificate* [name] - | | | +--rw name string - | | | +--rw cert-data trust-anchor-cert-cms + | | | | +--rw inline-definition + | | | | +--rw certificate* [name] + | | | | +--rw name string + | | | | +--rw cert-data trust-anchor-cert-cms + | | | +--:(central-truststore) {central-truststore-supported,certificates}? + | | | +--rw central-truststore-reference? ts:central-certificate-bag-ref | | +--rw tls13-epsks? empty {tlsc:server-auth-tls13-epsk}? | +--:(obfuscation) | +--rw shared-secret? string diff --git a/libraries/tacacsrs_config/yang/feature-flags.ini b/libraries/tacacsrs_config/yang/feature-flags.ini index f0c6ebc..6a39ad9 100644 --- a/libraries/tacacsrs_config/yang/feature-flags.ini +++ b/libraries/tacacsrs_config/yang/feature-flags.ini @@ -38,7 +38,7 @@ asymmetric-keys = true # The 'central-keystore-supported' feature indicates that the server supports the central # keystore (i.e., fully implements the 'ietf-keystore' module). -central-keystore-supported = false +central-keystore-supported = true # The 'inline-definitions-supported' feature indicates that the server supports locally # defined keys. @@ -73,7 +73,7 @@ tls13 = true [ietf-truststore] # The 'central-truststore-supported' feature indicates that the server supports the # truststore (i.e., implements the 'ietf-truststore' module). -central-truststore-supported = false +central-truststore-supported = true # The 'certificates' feature indicates that the server implements the # /truststore/certificate-bags subtree. diff --git a/libraries/tacacsrs_config/yang/generation-manifest.json b/libraries/tacacsrs_config/yang/generation-manifest.json new file mode 100644 index 0000000..056a2e0 --- /dev/null +++ b/libraries/tacacsrs_config/yang/generation-manifest.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "source": { + "repository": "https://github.com/YangModels/yang.git", + "commit": "97ca2414920c0de09171327b84aa11395f79e284", + "module": "standard/ietf/RFC/ietf-system-tacacs-plus@2026-03-31.yang", + "moduleSha256": "2a58836b7117320f847eb5965f851c9a83d2a13180ecd2f5a62daff5f607f9fd" + }, + "tools": { + "pyang": "2.7.1" + }, + "artifacts": { + "feature-flags.ini": "b26b330ef0da9d2de3ca4194ada15d5ed200f5774a4123c230d389ec8193f320", + "expanded-tree.txt": "720d1e975551b31c9c1acefd5602995d35b6e022b5194894355760c8c4cb86f0", + "../src/generated.rs": "b08ec4522d028cfb22fe7bbee0baf89e6943c2b5f29a0558a12d48130f0f4607" + } +} \ No newline at end of file diff --git a/libraries/tacacsrs_config/yang/test_expand_yang_tree.py b/libraries/tacacsrs_config/yang/test_expand_yang_tree.py new file mode 100644 index 0000000..3eedf35 --- /dev/null +++ b/libraries/tacacsrs_config/yang/test_expand_yang_tree.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import configparser +import os +import subprocess +import stat +import tempfile +import unittest +from pathlib import Path + +from expand_yang_tree import _remove_readonly, _verify_repo_revision +from verify_generated import load_manifest, verify_manifest + + +class VerifyRepoRevisionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.repo = Path(self.temporary_directory.name) + subprocess.run(["git", "init", "-q", str(self.repo)], check=True) + subprocess.run(["git", "config", "user.name", "P2 Test"], cwd=self.repo, check=True) + subprocess.run( + ["git", "config", "user.email", "p2-test@example.invalid"], + cwd=self.repo, + check=True, + ) + (self.repo / "input.yang").write_text("module input {}", encoding="utf-8") + subprocess.run(["git", "add", "input.yang"], cwd=self.repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "input"], cwd=self.repo, check=True) + self.commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def test_accepts_expected_detached_commit(self) -> None: + subprocess.run(["git", "checkout", "-q", "--detach", self.commit], cwd=self.repo, check=True) + + _verify_repo_revision(self.repo, self.commit) + + def test_rejects_wrong_commit(self) -> None: + subprocess.run(["git", "checkout", "-q", "--detach", self.commit], cwd=self.repo, check=True) + + with self.assertRaisesRegex(RuntimeError, "expected 000000"): + _verify_repo_revision(self.repo, "0" * 40) + + def test_rejects_attached_head(self) -> None: + with self.assertRaisesRegex(RuntimeError, "expected detached HEAD"): + _verify_repo_revision(self.repo, self.commit) + + def test_remove_readonly_retries_after_making_file_writable(self) -> None: + readonly_file = self.repo / "readonly" + readonly_file.write_text("content", encoding="utf-8") + readonly_file.chmod(stat.S_IREAD) + + _remove_readonly(os.unlink, str(readonly_file), None) + + self.assertFalse(readonly_file.exists()) + + +class FeatureFlagScopeTests(unittest.TestCase): + def test_only_reviewed_optional_features_are_enabled(self) -> None: + parser = configparser.ConfigParser(interpolation=None) + parser.read(Path(__file__).with_name("feature-flags.ini"), encoding="utf-8") + actual = { + section: {name: parser.getboolean(section, name) for name in parser[section]} + for section in parser.sections() + } + + self.assertEqual( + actual, + { + "ietf-system-tacacs-plus": {"credential-reference": True}, + "ietf-crypto-types": { + "certificate-expiration-notification": False, + "cleartext-private-keys": True, + "cleartext-symmetric-keys": True, + "csr-generation": False, + "encrypted-private-keys": False, + "encrypted-symmetric-keys": False, + "hidden-private-keys": False, + "hidden-symmetric-keys": False, + }, + "ietf-keystore": { + "asymmetric-keys": True, + "central-keystore-supported": True, + "inline-definitions-supported": True, + "symmetric-keys": True, + }, + "ietf-tls-client": { + "client-ident-raw-public-key": False, + "client-ident-tls13-epsk": True, + "server-auth-raw-public-key": False, + "server-auth-tls13-epsk": True, + }, + "ietf-tls-common": {"hello-params": False, "tls13": True}, + "ietf-truststore": { + "central-truststore-supported": True, + "certificates": True, + "inline-definitions-supported": True, + "public-keys": True, + }, + "tacacsrs": {"psk-dhe-ke-hello-params": True}, + }, + ) + + def test_generation_manifest_matches_reviewed_inputs_and_outputs(self) -> None: + verify_manifest(load_manifest()) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/libraries/tacacsrs_config/yang/verify_generated.py b/libraries/tacacsrs_config/yang/verify_generated.py new file mode 100644 index 0000000..845a765 --- /dev/null +++ b/libraries/tacacsrs_config/yang/verify_generated.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Regenerate TACACS+ model artifacts and verify pinned deterministic output.""" + +from __future__ import annotations + +import argparse +import difflib +import hashlib +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pyang + +from expand_yang_tree import ( + CACHE_DIR, + TACACS_MODULE, + YANG_MODELS_COMMIT, + YANG_MODELS_REPO, +) + +SCRIPT_DIR = Path(__file__).resolve().parent +MANIFEST_PATH = SCRIPT_DIR / "generation-manifest.json" + + +def canonical_bytes(path: Path) -> bytes: + """Return UTF-8 file content with explicit canonical LF line endings.""" + return path.read_bytes().replace(b"\r\n", b"\n") + + +def sha256(path: Path) -> str: + return hashlib.sha256(canonical_bytes(path)).hexdigest() + + +def load_manifest() -> dict[str, object]: + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def run_generator(output_path: Path, output_format: str, clean: bool = False) -> None: + command = [ + sys.executable, + str(SCRIPT_DIR / "expand_yang_tree.py"), + "--features-ini", + str(SCRIPT_DIR / "feature-flags.ini"), + "--format", + output_format, + "--output", + str(output_path), + ] + if clean: + command.append("--clean") + subprocess.run(command, cwd=SCRIPT_DIR, check=True) + + +def assert_equal(expected: Path, actual: Path, label: str) -> None: + expected_content = canonical_bytes(expected) + actual_content = canonical_bytes(actual) + if expected_content == actual_content: + return + + difference = "".join( + difflib.unified_diff( + expected_content.decode("utf-8").splitlines(keepends=True), + actual_content.decode("utf-8").splitlines(keepends=True), + fromfile=str(expected), + tofile=str(actual), + n=3, + ) + ) + raise RuntimeError(f"{label} differs:\n{difference[:4000]}") + + +def verify_manifest(manifest: dict[str, object]) -> None: + source = manifest["source"] + tools = manifest["tools"] + artifacts = manifest["artifacts"] + if not isinstance(source, dict) or not isinstance(tools, dict) or not isinstance(artifacts, dict): + raise RuntimeError("generation manifest has invalid section types") + + expected_source = { + "repository": YANG_MODELS_REPO, + "commit": YANG_MODELS_COMMIT, + "module": f"standard/ietf/RFC/{TACACS_MODULE}", + } + for key, expected in expected_source.items(): + if source.get(key) != expected: + raise RuntimeError(f"generation manifest source.{key} must be {expected}") + + if tools.get("pyang") != pyang.__version__: + raise RuntimeError( + f"pyang version {pyang.__version__} does not match manifest {tools.get('pyang')}" + ) + + for relative_path, expected_hash in artifacts.items(): + artifact_path = (SCRIPT_DIR / relative_path).resolve() + actual_hash = sha256(artifact_path) + if actual_hash != expected_hash: + raise RuntimeError( + f"{relative_path} SHA-256 {actual_hash} does not match manifest {expected_hash}" + ) + + +def verify_source_module(manifest: dict[str, object]) -> None: + source = manifest["source"] + if not isinstance(source, dict): + raise RuntimeError("generation manifest source must be an object") + module_path = CACHE_DIR / "yang-models" / str(source["module"]) + actual_hash = sha256(module_path) + if actual_hash != source["moduleSha256"]: + raise RuntimeError( + f"pinned source module SHA-256 {actual_hash} does not match manifest " + f"{source['moduleSha256']}" + ) + + +def verify_generated(clean: bool) -> None: + manifest = load_manifest() + verify_manifest(manifest) + + with tempfile.TemporaryDirectory(prefix="tacacsrs-yang-verify-") as temporary_directory: + temporary_path = Path(temporary_directory) + first_tree = temporary_path / "first-tree.txt" + first_rust = temporary_path / "first-generated.rs" + second_tree = temporary_path / "second-tree.txt" + second_rust = temporary_path / "second-generated.rs" + + run_generator(first_tree, "tree", clean=clean) + verify_source_module(manifest) + run_generator(first_rust, "rust") + run_generator(second_tree, "tree") + run_generator(second_rust, "rust") + + assert_equal(first_tree, second_tree, "tree regeneration is not deterministic") + assert_equal(first_rust, second_rust, "Rust regeneration is not deterministic") + assert_equal(SCRIPT_DIR / "expanded-tree.txt", first_tree, "checked-in expanded tree") + assert_equal( + SCRIPT_DIR.parent / "src" / "generated.rs", + first_rust, + "checked-in generated Rust", + ) + + print( + "PASS: pinned source, manifest hashes, deterministic regeneration, and checked-in artifacts match." + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--clean", + action="store_true", + help="Remove and recreate the pinned sparse YANG cache before verification", + ) + args = parser.parse_args() + verify_generated(clean=args.clean) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/libraries/tacacsrs_credential_resolution/Cargo.toml b/libraries/tacacsrs_credential_resolution/Cargo.toml new file mode 100644 index 0000000..1654a7a --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tacacsrs-credential-resolution" +description = "Provider-neutral central credential request and resolution contracts for TACACS+ configuration." +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +async-trait = "0.1" +tacacsrs-config = { path = "../tacacsrs_config" } +zeroize = "1.9.0" + +[dev-dependencies] +serde = "1.0.228" +static_assertions = "1.1.0" +tokio = { version = "1.52.3", features = ["macros", "rt"] } + +[lints] +workspace = true \ No newline at end of file diff --git a/libraries/tacacsrs_credential_resolution/README.md b/libraries/tacacsrs_credential_resolution/README.md new file mode 100644 index 0000000..152b181 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/README.md @@ -0,0 +1,53 @@ +# tacacsrs-credential-resolution + +`tacacsrs-credential-resolution` defines provider-neutral contracts for planning and resolving central credential references found in an enumerated `tacacsrs-config` server. + +The crate owns: + +- deterministic typed request plans; +- an asynchronous resolver trait; +- typed certificate, private-key, symmetric-key, and trust-bag material; +- a closed result set that validates request/response slots and variants; +- sanitized typed failures; +- a deterministic fake resolver for tests. + +It performs no filesystem, `SONiC`, `ConfigDB`, watcher, permission, retry, or runtime connection work. Providers inspect opaque references only through explicit request accessors. Debug and public error output omit opaque references and secret bytes. + +```text +enumerated server + | + v +ResolutionPlan -> CredentialResolver -> ResolvedCredentialSet + | | + +-- stable server/field context +-- validated slots and variants +``` + +P3 supplies the `SONiC` provider and projects the closed result set into runtime networking inputs. + +## Workflow + +1. Parse and validate RFC 7951 JSON with `tacacsrs-config`. +2. Enumerate config-local client and server credential bundles. +3. Build a `ResolutionPlan` for each enumerated server. +4. Execute the plan with a `CredentialResolver`. +5. Consume the validated `ResolvedCredentialSet` by request slot. + +Planning emits deterministic requests for certificate-with-key, TLS 1.3 symmetric key, CA certificate bag, and end-entity certificate bag usages. It rejects unexpanded local bundle references and structurally incomplete central certificate requests. Result-set construction rejects missing, duplicate, unexpected, and wrong-variant responses. + +Central reference strings stay opaque. The generic API does not impose a provider grammar, convert a reference into a path, check existence or permissions, watch for changes, or retry retrieval. Provider-specific errors cross this boundary only as sanitized `ProviderErrorKind` values. + +## Secret Material + +`SecretBytes` directly owns a `zeroize::Zeroizing>` and exposes a value only through the explicitly named borrowed `expose_secret` method. It does not implement `Clone`, serde, `Display`, equality, or hashing. Secret-bearing credentials, responses, and result sets preserve those restrictions. Custom `Debug` implementations redact private keys, symmetric keys, opaque references, and provider details; public certificate bytes report length only. + +The P2 test suite enforces these properties with compile-time negative trait assertions and public-behavior redaction tests. It does not inspect freed memory or use unsafe code. + +## Example + +The runnable example uses `FakeCredentialResolver` to demonstrate the generic handoff without choosing a provider: + +```bash +cargo run -p tacacsrs-credential-resolution --example central_resolution +``` + +A production resolver implements `CredentialResolver`, reads references only through explicit request accessors, and returns the material variant requested by each slot. P3 supplies the platform provider and the projection from `ResolvedCredentialSet` to networking connection inputs. diff --git a/libraries/tacacsrs_credential_resolution/examples/central_resolution.rs b/libraries/tacacsrs_credential_resolution/examples/central_resolution.rs new file mode 100644 index 0000000..221ebba --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/examples/central_resolution.rs @@ -0,0 +1,69 @@ +use tacacsrs_config::{enumerate_server, parse_yang_json}; +use tacacsrs_credential_resolution::{ + CertificateBagMaterial, CertificateWithKeyMaterial, CredentialKind, FakeCredentialResolver, + PublicBytes, ResolutionPlan, ResolvedCredential, SecretBytes, resolve_plan, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [{ + "id": "central-client", + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "provider-defined-key-reference", + "certificate": "provider-defined-certificate-reference" + } + } + }], + "server": [{ + "name": "primary", + "server-type": "accounting", + "address": "192.0.2.30", + "port": 49, + "client-identity": {"credentials-reference": "central-client"}, + "server-authentication": { + "ca-certs": { + "central-truststore-reference": "provider-defined-ca-reference" + } + } + }] + } + }"#, + )?; + let server = enumerate_server(&config, "primary")?; + let plan = ResolutionPlan::from_server(&server)?; + + assert_eq!(plan.requests().len(), 2); + assert_eq!(plan.requests()[0].kind(), CredentialKind::CertificateWithKey); + assert_eq!(plan.requests()[1].kind(), CredentialKind::CaCertificateBag); + + let resolver = FakeCredentialResolver::new() + .with_response( + plan.requests()[0].slot(), + ResolvedCredential::CertificateWithKey(CertificateWithKeyMaterial { + certificate: PublicBytes::new(b"example certificate bytes".to_vec()), + private_key: SecretBytes::new(b"example private key bytes".to_vec()), + }), + ) + .with_response( + plan.requests()[1].slot(), + ResolvedCredential::CaCertificateBag(CertificateBagMaterial { + certificates: vec![PublicBytes::new(b"example CA certificate bytes".to_vec())], + }), + ); + let result_set = resolve_plan(&plan, &resolver).await?; + + let ResolvedCredential::CertificateWithKey(client_identity) = result_set + .credential(plan.requests()[0].slot()) + .expect("client identity result") + else { + unreachable!("the closed result set already checked the response variant"); + }; + assert!(!client_identity.private_key.expose_secret().is_empty()); + println!("resolved {} credential slots", result_set.len()); + + Ok(()) +} diff --git a/libraries/tacacsrs_credential_resolution/src/error.rs b/libraries/tacacsrs_credential_resolution/src/error.rs new file mode 100644 index 0000000..b215e3a --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/error.rs @@ -0,0 +1,225 @@ +//! Sanitized typed resolution failures. + +use std::fmt; + +use tacacsrs_config::EnumerationRequiredError; + +use crate::{CredentialKind, RequestContext, RequestSlot}; + +/// Stable resolution failure category. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ResolutionErrorKind { + /// Config-local credential bundle expansion is required first. + EnumerationRequired, + /// A generated central container cannot form a usable request. + IncompleteRequest, + /// Provider could not find requested material. + NotFound, + /// Provider denied access to requested material. + AccessDenied, + /// Provider returned malformed or invalid material. + InvalidMaterial, + /// Provider is temporarily unavailable. + Unavailable, + /// A response slot is not part of the plan. + UnexpectedResponse, + /// More than one response was returned for a slot. + DuplicateResponse, + /// No response was returned for a required slot. + MissingResponse, + /// Resolved material variant does not match the request. + ResponseMismatch, +} + +/// Failure category that a provider may report for one request. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ProviderErrorKind { + /// Provider could not find requested material. + NotFound, + /// Provider denied access to requested material. + AccessDenied, + /// Provider returned malformed or invalid material. + InvalidMaterial, + /// Provider is temporarily unavailable. + Unavailable, +} + +impl From for ResolutionErrorKind { + fn from(kind: ProviderErrorKind) -> Self { + match kind { + ProviderErrorKind::NotFound => Self::NotFound, + ProviderErrorKind::AccessDenied => Self::AccessDenied, + ProviderErrorKind::InvalidMaterial => Self::InvalidMaterial, + ProviderErrorKind::Unavailable => Self::Unavailable, + } + } +} + +/// Provider-neutral error containing typed context but no raw reference, path, +/// secret value, or provider source error. +#[derive(Clone, Eq, PartialEq)] +pub struct ResolutionError { + kind: ResolutionErrorKind, + context: Option, + slot: Option, + expected: Option, + actual: Option, +} + +impl ResolutionError { + pub(crate) fn incomplete_request(context: RequestContext, expected: CredentialKind) -> Self { + Self { + kind: ResolutionErrorKind::IncompleteRequest, + context: Some(context), + slot: None, + expected: Some(expected), + actual: None, + } + } + + /// Creates a sanitized provider failure for one request. + #[must_use] + pub fn provider(kind: ProviderErrorKind, context: &RequestContext) -> Self { + Self { + kind: kind.into(), + context: Some(context.clone()), + slot: None, + expected: None, + actual: None, + } + } + + pub(crate) fn unexpected_response(slot: RequestSlot) -> Self { + Self::response_error(ResolutionErrorKind::UnexpectedResponse, slot, None, None) + } + + pub(crate) fn duplicate_response(slot: RequestSlot) -> Self { + Self::response_error(ResolutionErrorKind::DuplicateResponse, slot, None, None) + } + + pub(crate) fn missing_response( + slot: RequestSlot, + context: RequestContext, + expected: CredentialKind, + ) -> Self { + Self { + kind: ResolutionErrorKind::MissingResponse, + context: Some(context), + slot: Some(slot), + expected: Some(expected), + actual: None, + } + } + + pub(crate) fn response_mismatch( + slot: RequestSlot, + context: RequestContext, + expected: CredentialKind, + actual: CredentialKind, + ) -> Self { + Self { + kind: ResolutionErrorKind::ResponseMismatch, + context: Some(context), + slot: Some(slot), + expected: Some(expected), + actual: Some(actual), + } + } + + fn response_error( + kind: ResolutionErrorKind, + slot: RequestSlot, + expected: Option, + actual: Option, + ) -> Self { + Self { + kind, + context: None, + slot: Some(slot), + expected, + actual, + } + } + + /// Returns the stable failure category. + #[must_use] + pub const fn kind(&self) -> ResolutionErrorKind { + self.kind + } + + /// Returns secret-free server and field context when available. + #[must_use] + pub const fn context(&self) -> Option<&RequestContext> { + self.context.as_ref() + } + + /// Returns the affected response slot when available. + #[must_use] + pub const fn slot(&self) -> Option { + self.slot + } + + /// Returns the expected credential kind when relevant. + #[must_use] + pub const fn expected(&self) -> Option { + self.expected + } + + /// Returns the actual credential kind for a mismatch. + #[must_use] + pub const fn actual(&self) -> Option { + self.actual + } +} + +impl From for ResolutionError { + fn from(error: EnumerationRequiredError) -> Self { + let field_path = match error.field() { + tacacsrs_config::UnexpandedCredentialField::ClientIdentity => { + "client-identity/credentials-reference" + } + tacacsrs_config::UnexpandedCredentialField::ServerAuthentication => { + "server-authentication/credentials-reference" + } + }; + Self { + kind: ResolutionErrorKind::EnumerationRequired, + context: Some(RequestContext::new(error.server_name(), field_path)), + slot: None, + expected: None, + actual: None, + } + } +} + +impl fmt::Display for ResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "credential resolution {:?}", self.kind)?; + if let Some(context) = &self.context { + write!( + formatter, + " for server '{}' field '{}'", + context.server_name(), + context.field_path() + )?; + } + if let Some(slot) = self.slot { + write!(formatter, " at slot {}", slot.index())?; + } + if let Some(expected) = self.expected { + write!(formatter, "; expected {expected:?}")?; + } + if let Some(actual) = self.actual { + write!(formatter, ", received {actual:?}")?; + } + Ok(()) + } +} + +impl fmt::Debug for ResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl std::error::Error for ResolutionError {} diff --git a/libraries/tacacsrs_credential_resolution/src/fake.rs b/libraries/tacacsrs_credential_resolution/src/fake.rs new file mode 100644 index 0000000..9c72b3d --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/fake.rs @@ -0,0 +1,60 @@ +//! Deterministic fake resolver for tests and provider integration examples. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use async_trait::async_trait; + +use crate::{CredentialRequest, ProviderErrorKind, ResolutionError, ResolvedCredential, RequestSlot}; + +/// In-memory slot-indexed resolver that consumes configured responses once. +#[derive(Default)] +pub struct FakeCredentialResolver { + responses: Mutex>>, +} + +impl FakeCredentialResolver { + /// Creates an empty fake resolver. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Configures resolved material for one request slot. + #[must_use] + pub fn with_response(self, slot: RequestSlot, credential: ResolvedCredential) -> Self { + self.responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(slot, Ok(credential)); + self + } + + /// Configures a sanitized provider failure for one request slot. + #[must_use] + pub fn with_error(self, slot: RequestSlot, kind: ProviderErrorKind) -> Self { + self.responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(slot, Err(kind)); + self + } +} + +#[async_trait] +impl crate::CredentialResolver for FakeCredentialResolver { + async fn resolve( + &self, + request: &CredentialRequest, + ) -> Result { + let response = self + .responses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request.slot()) + .ok_or_else(|| { + ResolutionError::provider(ProviderErrorKind::NotFound, request.context()) + })?; + response.map_err(|kind| ResolutionError::provider(kind, request.context())) + } +} diff --git a/libraries/tacacsrs_credential_resolution/src/lib.rs b/libraries/tacacsrs_credential_resolution/src/lib.rs new file mode 100644 index 0000000..73f5783 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/lib.rs @@ -0,0 +1,21 @@ +#![doc = include_str!("../README.md")] + +mod error; +mod fake; +mod material; +mod request; +mod resolver; +mod result_set; + +pub use error::{ProviderErrorKind, ResolutionError, ResolutionErrorKind}; +pub use fake::FakeCredentialResolver; +pub use material::{ + CertificateBagMaterial, CertificateWithKeyMaterial, PublicBytes, ResolvedCredential, + SecretBytes, +}; +pub use request::{ + CredentialKind, CredentialReference, CredentialRequest, RequestContext, RequestSlot, + ResolutionPlan, +}; +pub use resolver::{CredentialResolver, resolve_plan}; +pub use result_set::{ResolvedCredentialSet, ResolvedResponse}; diff --git a/libraries/tacacsrs_credential_resolution/src/material.rs b/libraries/tacacsrs_credential_resolution/src/material.rs new file mode 100644 index 0000000..5778d73 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/material.rs @@ -0,0 +1,98 @@ +//! Typed resolved credential material with non-revealing formatting. + +use std::fmt; + +use zeroize::Zeroizing; + +use crate::CredentialKind; + +/// Secret byte ownership that zeroizes its allocation on drop. +pub struct SecretBytes(Zeroizing>); + +impl SecretBytes { + /// Takes ownership of secret bytes. + #[must_use] + pub fn new(bytes: Vec) -> Self { + Self(Zeroizing::new(bytes)) + } + + /// Explicitly borrows the secret value for runtime projection. + #[must_use] + pub fn expose_secret(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretBytes()") + } +} + +/// Public certificate bytes whose debug output reveals length only. +pub struct PublicBytes(Vec); + +impl PublicBytes { + /// Takes ownership of public bytes. + #[must_use] + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// Borrows the public value. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for PublicBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PublicBytes") + .field("length", &self.0.len()) + .finish() + } +} + +/// Resolved certificate and corresponding private key. +#[derive(Debug)] +pub struct CertificateWithKeyMaterial { + /// Public end-entity certificate. + pub certificate: PublicBytes, + /// Secret private key corresponding to the certificate. + pub private_key: SecretBytes, +} + +/// Resolved public certificate bag. +#[derive(Debug)] +pub struct CertificateBagMaterial { + /// Public certificates in provider order. + pub certificates: Vec, +} + +/// Provider-neutral resolved credential variant. +#[derive(Debug)] +pub enum ResolvedCredential { + /// End-entity certificate and private key. + CertificateWithKey(CertificateWithKeyMaterial), + /// TLS 1.3 external pre-shared key. + SymmetricKey(SecretBytes), + /// CA certificate bag. + CaCertificateBag(CertificateBagMaterial), + /// End-entity certificate bag. + EeCertificateBag(CertificateBagMaterial), +} + +impl ResolvedCredential { + /// Returns the material kind for request/response matching. + #[must_use] + pub const fn kind(&self) -> CredentialKind { + match self { + Self::CertificateWithKey(_) => CredentialKind::CertificateWithKey, + Self::SymmetricKey(_) => CredentialKind::SymmetricKey, + Self::CaCertificateBag(_) => CredentialKind::CaCertificateBag, + Self::EeCertificateBag(_) => CredentialKind::EeCertificateBag, + } + } +} diff --git a/libraries/tacacsrs_credential_resolution/src/request.rs b/libraries/tacacsrs_credential_resolution/src/request.rs new file mode 100644 index 0000000..b816971 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/request.rs @@ -0,0 +1,303 @@ +//! Closed resolution plans built from provider-neutral config inspection slots. + +use std::fmt; + +use tacacsrs_config::{ + CentralCredentialReference, CentralCredentialSlot, CentralCredentialUsage, TacacsPlusServer, + inspect_central_references, +}; + +use crate::ResolutionError; + +/// Stable ordinal identifying one request within a [`ResolutionPlan`]. +#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RequestSlot(usize); + +impl RequestSlot { + /// Reconstructs a slot index for a batched provider response. + #[must_use] + pub const fn from_index(index: usize) -> Self { + Self(index) + } + + /// Returns the zero-based deterministic slot index. + #[must_use] + pub const fn index(self) -> usize { + self.0 + } +} + +/// Expected resolved material variant for a request. +#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum CredentialKind { + /// End-entity certificate plus private key. + CertificateWithKey, + /// TLS 1.3 external pre-shared key. + SymmetricKey, + /// CA certificate bag. + CaCertificateBag, + /// End-entity certificate bag. + EeCertificateBag, +} + +/// Stable request context containing no endpoint or raw reference value. +#[derive(Debug, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RequestContext { + server_name: String, + field_path: &'static str, +} + +impl RequestContext { + pub(crate) fn new(server_name: impl Into, field_path: &'static str) -> Self { + Self { + server_name: server_name.into(), + field_path, + } + } + + /// Returns the configured server name. + #[must_use] + pub fn server_name(&self) -> &str { + &self.server_name + } + + /// Returns the stable RFC model field path. + #[must_use] + pub const fn field_path(&self) -> &'static str { + self.field_path + } +} + +/// Owned opaque reference passed to a provider only through explicit access. +#[derive(Eq, PartialEq)] +pub enum CredentialReference { + /// Structured central certificate-with-key reference. + CertificateWithKey { + /// Opaque central asymmetric-key reference, when present. + asymmetric_key: Option, + /// Opaque central certificate reference, when present. + certificate: Option, + }, + /// Opaque central symmetric-key reference. + SymmetricKey(String), + /// Opaque central truststore certificate-bag reference. + CertificateBag(String), +} + +impl CredentialReference { + /// Returns the structured central certificate-with-key components. + #[must_use] + pub fn certificate_with_key(&self) -> Option<(Option<&str>, Option<&str>)> { + match self { + Self::CertificateWithKey { + asymmetric_key, + certificate, + } => Some((asymmetric_key.as_deref(), certificate.as_deref())), + Self::SymmetricKey(_) | Self::CertificateBag(_) => None, + } + } + + /// Returns an opaque symmetric-key reference for provider lookup. + #[must_use] + pub fn symmetric_key(&self) -> Option<&str> { + match self { + Self::SymmetricKey(reference) => Some(reference), + Self::CertificateWithKey { .. } | Self::CertificateBag(_) => None, + } + } + + /// Returns an opaque certificate-bag reference for provider lookup. + #[must_use] + pub fn certificate_bag(&self) -> Option<&str> { + match self { + Self::CertificateBag(reference) => Some(reference), + Self::CertificateWithKey { .. } | Self::SymmetricKey(_) => None, + } + } +} + +impl fmt::Debug for CredentialReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CertificateWithKey { .. } => formatter + .debug_struct("CertificateWithKey") + .field("reference", &"") + .finish(), + Self::SymmetricKey(_) => formatter + .debug_tuple("SymmetricKey") + .field(&"") + .finish(), + Self::CertificateBag(_) => formatter + .debug_tuple("CertificateBag") + .field(&"") + .finish(), + } + } +} + +/// One typed provider-neutral resolution request. +pub struct CredentialRequest { + slot: RequestSlot, + context: RequestContext, + kind: CredentialKind, + reference: CredentialReference, +} + +impl CredentialRequest { + /// Returns the request slot. + #[must_use] + pub const fn slot(&self) -> RequestSlot { + self.slot + } + + /// Returns the stable secret-free request context. + #[must_use] + pub const fn context(&self) -> &RequestContext { + &self.context + } + + /// Returns the expected material kind. + #[must_use] + pub const fn kind(&self) -> CredentialKind { + self.kind + } + + /// Explicitly exposes the opaque owned reference to a provider. + #[must_use] + pub const fn reference(&self) -> &CredentialReference { + &self.reference + } +} + +impl fmt::Debug for CredentialRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CredentialRequest") + .field("slot", &self.slot) + .field("context", &self.context) + .field("kind", &self.kind) + .field("reference", &"") + .finish() + } +} + +/// Ordered closed request plan for one direct or enumerated server. +pub struct ResolutionPlan { + requests: Vec, +} + +impl ResolutionPlan { + /// Builds a deterministic provider-neutral plan from one server. + /// + /// # Errors + /// + /// Returns [`ResolutionErrorKind::EnumerationRequired`](crate::ResolutionErrorKind::EnumerationRequired) + /// if config-local bundle references remain. Returns + /// [`ResolutionErrorKind::IncompleteRequest`](crate::ResolutionErrorKind::IncompleteRequest) when + /// the generated RFC model preserves a central container that lacks the + /// fields needed to form a provider request. + pub fn from_server(server: &TacacsPlusServer) -> Result { + let slots = inspect_central_references(server).map_err(ResolutionError::from)?; + let requests = slots + .into_iter() + .enumerate() + .map(|(index, slot)| request_from_slot(RequestSlot(index), slot)) + .collect::, _>>()?; + Ok(Self { requests }) + } + + /// Returns all requests in deterministic field order. + #[must_use] + pub fn requests(&self) -> &[CredentialRequest] { + &self.requests + } + + /// Returns the number of expected responses. + #[must_use] + pub fn len(&self) -> usize { + self.requests.len() + } + + /// Returns whether the plan contains no provider requests. + #[must_use] + pub fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + #[cfg(test)] + pub(crate) fn reverse_requests_for_test(&mut self) { + self.requests.reverse(); + } +} + +impl fmt::Debug for ResolutionPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolutionPlan") + .field("requests", &self.requests) + .finish() + } +} + +fn request_from_slot( + slot: RequestSlot, + inspected: CentralCredentialSlot<'_>, +) -> Result { + let usage = inspected.usage(); + let context = RequestContext { + server_name: inspected.server_name().to_owned(), + field_path: usage.field_path(), + }; + let (kind, reference) = match (usage, inspected.reference()) { + ( + CentralCredentialUsage::ClientCertificateWithKey, + CentralCredentialReference::CertificateWithKey { + asymmetric_key, + certificate, + }, + ) if asymmetric_key.is_some() || certificate.is_some() => ( + CredentialKind::CertificateWithKey, + CredentialReference::CertificateWithKey { + asymmetric_key: asymmetric_key.map(str::to_owned), + certificate: certificate.map(str::to_owned), + }, + ), + ( + CentralCredentialUsage::ClientTls13Epsk, + CentralCredentialReference::SymmetricKey(reference), + ) => { + (CredentialKind::SymmetricKey, CredentialReference::SymmetricKey(reference.to_owned())) + } + ( + CentralCredentialUsage::ServerCaCertificateBag, + CentralCredentialReference::CertificateBag(reference), + ) => ( + CredentialKind::CaCertificateBag, + CredentialReference::CertificateBag(reference.to_owned()), + ), + ( + CentralCredentialUsage::ServerEeCertificateBag, + CentralCredentialReference::CertificateBag(reference), + ) => ( + CredentialKind::EeCertificateBag, + CredentialReference::CertificateBag(reference.to_owned()), + ), + _ => return Err(ResolutionError::incomplete_request(context, kind_for_usage(usage))), + }; + + Ok(CredentialRequest { + slot, + context, + kind, + reference, + }) +} + +const fn kind_for_usage(usage: CentralCredentialUsage) -> CredentialKind { + match usage { + CentralCredentialUsage::ClientCertificateWithKey => CredentialKind::CertificateWithKey, + CentralCredentialUsage::ClientTls13Epsk => CredentialKind::SymmetricKey, + CentralCredentialUsage::ServerCaCertificateBag => CredentialKind::CaCertificateBag, + CentralCredentialUsage::ServerEeCertificateBag => CredentialKind::EeCertificateBag, + } +} diff --git a/libraries/tacacsrs_credential_resolution/src/resolver.rs b/libraries/tacacsrs_credential_resolution/src/resolver.rs new file mode 100644 index 0000000..b789467 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/resolver.rs @@ -0,0 +1,40 @@ +//! Async provider interface and plan execution. + +use async_trait::async_trait; + +use crate::{ + CredentialRequest, ResolutionError, ResolutionPlan, ResolvedCredential, ResolvedCredentialSet, + ResolvedResponse, +}; + +/// Provider-neutral asynchronous credential resolver. +#[async_trait] +pub trait CredentialResolver: Send + Sync { + /// Resolves one typed request. + /// + /// # Errors + /// + /// Returns a sanitized typed [`ResolutionError`] without raw references, + /// provider paths, or source errors. + async fn resolve( + &self, + request: &CredentialRequest, + ) -> Result; +} + +/// Resolves every request in deterministic plan order and validates the closed result set. +/// +/// # Errors +/// +/// Returns the first sanitized provider error or a request/result mismatch. +pub async fn resolve_plan( + plan: &ResolutionPlan, + resolver: &dyn CredentialResolver, +) -> Result { + let mut responses = Vec::with_capacity(plan.len()); + for request in plan.requests() { + let credential = resolver.resolve(request).await?; + responses.push(ResolvedResponse::new(request.slot(), credential)); + } + ResolvedCredentialSet::from_responses(plan, responses) +} diff --git a/libraries/tacacsrs_credential_resolution/src/result_set.rs b/libraries/tacacsrs_credential_resolution/src/result_set.rs new file mode 100644 index 0000000..b017770 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/src/result_set.rs @@ -0,0 +1,237 @@ +//! Closed request/result association and variant validation. + +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::fmt; + +use crate::{ + CredentialKind, ResolutionError, ResolutionPlan, ResolvedCredential, RequestContext, + RequestSlot, +}; + +/// One provider response associated with a request slot. +pub struct ResolvedResponse { + slot: RequestSlot, + credential: ResolvedCredential, +} + +impl ResolvedResponse { + /// Creates a response for one plan slot. + #[must_use] + pub const fn new(slot: RequestSlot, credential: ResolvedCredential) -> Self { + Self { slot, credential } + } +} + +impl fmt::Debug for ResolvedResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedResponse") + .field("slot", &self.slot) + .field("kind", &self.credential.kind()) + .finish() + } +} + +struct ResolvedEntry { + context: RequestContext, + kind: CredentialKind, + credential: ResolvedCredential, +} + +/// Complete validated result set for one resolution plan. +pub struct ResolvedCredentialSet { + entries: BTreeMap, +} + +impl ResolvedCredentialSet { + /// Validates provider responses against a closed request plan. + /// + /// # Errors + /// + /// Returns typed errors for missing, duplicate, unexpected, or wrong-variant responses. + pub fn from_responses( + plan: &ResolutionPlan, + responses: impl IntoIterator, + ) -> Result { + let expected_slots = plan + .requests() + .iter() + .map(crate::CredentialRequest::slot) + .collect::>(); + let mut responses_by_slot = BTreeMap::new(); + for response in responses { + if !expected_slots.contains(&response.slot) { + return Err(ResolutionError::unexpected_response(response.slot)); + } + match responses_by_slot.entry(response.slot) { + Entry::Vacant(entry) => { + entry.insert(response.credential); + } + Entry::Occupied(_) => { + return Err(ResolutionError::duplicate_response(response.slot)); + } + } + } + + let mut entries = BTreeMap::new(); + for request in plan.requests() { + let Some(credential) = responses_by_slot.remove(&request.slot()) else { + return Err(ResolutionError::missing_response( + request.slot(), + request.context().clone(), + request.kind(), + )); + }; + let actual = credential.kind(); + if actual != request.kind() { + return Err(ResolutionError::response_mismatch( + request.slot(), + request.context().clone(), + request.kind(), + actual, + )); + } + entries.insert( + request.slot(), + ResolvedEntry { + context: request.context().clone(), + kind: request.kind(), + credential, + }, + ); + } + Ok(Self { entries }) + } + + /// Returns the number of resolved entries. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns whether no credentials were required. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Returns resolved material by request slot. + #[must_use] + pub fn credential(&self, slot: RequestSlot) -> Option<&ResolvedCredential> { + self.entries.get(&slot).map(|entry| &entry.credential) + } + + /// Returns the stable secret-free context for a request slot. + #[must_use] + pub fn context(&self, slot: RequestSlot) -> Option<&RequestContext> { + self.entries.get(&slot).map(|entry| &entry.context) + } +} + +impl fmt::Debug for ResolvedCredentialSet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let entries = self + .entries + .iter() + .map(|(slot, entry)| (*slot, &entry.context, entry.kind)) + .collect::>(); + formatter + .debug_struct("ResolvedCredentialSet") + .field("entries", &entries) + .finish() + } +} + +#[cfg(test)] +mod tests { + use tacacsrs_config::parse_yang_json; + + use super::*; + use crate::{CertificateBagMaterial, CertificateWithKeyMaterial, PublicBytes, SecretBytes}; + + #[test] + fn lookup_uses_request_slot_when_plan_iteration_order_changes() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "reordered-plan", + "server-type": "accounting", + "address": "192.0.2.40", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "key-reference", + "certificate": "certificate-reference" + } + } + }, + "server-authentication": { + "ca-certs": {"central-truststore-reference": "ca-reference"}, + "ee-certs": {"central-truststore-reference": "ee-reference"} + } + }] + } + }"#, + ) + .expect("central credential config"); + let mut plan = ResolutionPlan::from_server(&config.server[0]).expect("resolution plan"); + plan.reverse_requests_for_test(); + + let responses = plan.requests().iter().map(|request| { + let credential = match request.kind() { + CredentialKind::CertificateWithKey => { + ResolvedCredential::CertificateWithKey(CertificateWithKeyMaterial { + certificate: PublicBytes::new(b"certificate".to_vec()), + private_key: SecretBytes::new(b"private-key".to_vec()), + }) + } + CredentialKind::CaCertificateBag => { + ResolvedCredential::CaCertificateBag(CertificateBagMaterial { + certificates: vec![PublicBytes::new(b"ca".to_vec())], + }) + } + CredentialKind::EeCertificateBag => { + ResolvedCredential::EeCertificateBag(CertificateBagMaterial { + certificates: vec![PublicBytes::new(b"ee".to_vec())], + }) + } + CredentialKind::SymmetricKey => unreachable!("test plan has no symmetric key"), + }; + ResolvedResponse::new(request.slot(), credential) + }); + + let result = ResolvedCredentialSet::from_responses(&plan, responses) + .expect("responses should match reordered plan"); + + assert_eq!( + result + .credential(RequestSlot::from_index(0)) + .expect("slot 0") + .kind(), + CredentialKind::CertificateWithKey, + ); + assert_eq!( + result + .credential(RequestSlot::from_index(1)) + .expect("slot 1") + .kind(), + CredentialKind::CaCertificateBag, + ); + assert_eq!( + result + .credential(RequestSlot::from_index(2)) + .expect("slot 2") + .kind(), + CredentialKind::EeCertificateBag, + ); + assert_eq!( + result + .context(RequestSlot::from_index(0)) + .expect("slot 0 context") + .field_path(), + "client-identity/certificate", + ); + } +} diff --git a/libraries/tacacsrs_credential_resolution/tests/material_safety.rs b/libraries/tacacsrs_credential_resolution/tests/material_safety.rs new file mode 100644 index 0000000..4e61048 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/tests/material_safety.rs @@ -0,0 +1,89 @@ +use std::{fmt::Display, hash::Hash}; + +use serde::{Serialize, de::DeserializeOwned}; +use static_assertions::assert_not_impl_any; +use tacacsrs_credential_resolution::{ + CertificateWithKeyMaterial, CredentialKind, PublicBytes, ResolvedCredential, + ResolvedCredentialSet, ResolvedResponse, SecretBytes, +}; +use tacacsrs_config::parse_yang_json; + +assert_not_impl_any!(SecretBytes: Clone, Serialize, DeserializeOwned, Display, PartialEq, Eq, Hash); +assert_not_impl_any!(CertificateWithKeyMaterial: Clone, Serialize, DeserializeOwned, Display, PartialEq, Eq, Hash); +assert_not_impl_any!(ResolvedCredential: Clone, Serialize, DeserializeOwned, Display, PartialEq, Eq, Hash); +assert_not_impl_any!(ResolvedResponse: Clone, Serialize, DeserializeOwned, Display, PartialEq, Eq, Hash); +assert_not_impl_any!(ResolvedCredentialSet: Clone, Serialize, DeserializeOwned, Display, PartialEq, Eq, Hash); + +#[test] +fn secret_bytes_require_explicit_borrow_and_redact_debug() { + let secret = SecretBytes::new(b"symmetric-secret-value".to_vec()); + + assert_eq!(secret.expose_secret(), b"symmetric-secret-value"); + assert_eq!(format!("{secret:?}"), "SecretBytes()"); + assert!(!format!("{secret:?}").contains("symmetric-secret-value")); +} + +#[test] +fn secret_bearing_aggregate_debug_redacts_all_material() { + let credential = ResolvedCredential::CertificateWithKey(CertificateWithKeyMaterial { + certificate: PublicBytes::new(b"public-certificate-value".to_vec()), + private_key: SecretBytes::new(b"private-key-value".to_vec()), + }); + + let debug = format!("{credential:?}"); + assert!(debug.contains("CertificateWithKey")); + assert!(debug.contains("length: 24")); + assert!(debug.contains("")); + assert!(!debug.contains("public-certificate-value")); + assert!(!debug.contains("private-key-value")); +} + +#[test] +fn closed_result_debug_omits_secret_and_reference_values() { + let parsed = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "safety-server", + "server-type": "accounting", + "address": "192.0.2.15", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "opaque-central-reference", + "external-identity": "client" + } + } + }] + } + }"#, + ) + .expect("central EPSK config"); + let plan = tacacsrs_credential_resolution::ResolutionPlan::from_server(&parsed.server[0]) + .expect("resolution plan"); + let response = ResolvedResponse::new( + plan.requests()[0].slot(), + ResolvedCredential::SymmetricKey(SecretBytes::new(b"resolved-symmetric-secret".to_vec())), + ); + let response_debug = format!("{response:?}"); + assert!(response_debug.contains("SymmetricKey")); + assert!(!response_debug.contains("resolved-symmetric-secret")); + + let result = ResolvedCredentialSet::from_responses(&plan, [response]) + .expect("closed result set should match the plan"); + assert_eq!( + result + .credential(plan.requests()[0].slot()) + .expect("resolved credential") + .kind(), + CredentialKind::SymmetricKey, + ); + let result_debug = format!("{result:?}"); + for forbidden in [ + "resolved-symmetric-secret", + "opaque-central-reference", + "192.0.2.15", + ] { + assert!(!result_debug.contains(forbidden), "debug exposed {forbidden}"); + } +} diff --git a/libraries/tacacsrs_credential_resolution/tests/resolution.rs b/libraries/tacacsrs_credential_resolution/tests/resolution.rs new file mode 100644 index 0000000..fdf4e75 --- /dev/null +++ b/libraries/tacacsrs_credential_resolution/tests/resolution.rs @@ -0,0 +1,370 @@ +use tacacsrs_config::{enumerate_servers, parse_yang_json, pipeline}; +use tacacsrs_credential_resolution::{ + CertificateBagMaterial, CertificateWithKeyMaterial, CredentialKind, CredentialReference, + FakeCredentialResolver, ProviderErrorKind, PublicBytes, ResolutionErrorKind, ResolutionPlan, + ResolvedCredential, ResolvedCredentialSet, ResolvedResponse, SecretBytes, resolve_plan, +}; + +fn certificate_and_trust_server() -> tacacsrs_config::TacacsPlusServer { + parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "certificate-server", + "server-type": "accounting", + "address": "10.0.5.1", + "port": 49, + "client-identity": { + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "secret-reference-key", + "certificate": "secret-reference-certificate" + } + } + }, + "server-authentication": { + "ca-certs": {"central-truststore-reference": "secret-reference-ca"}, + "ee-certs": {"central-truststore-reference": "secret-reference-ee"} + } + }] + } + }"#, + ) + .expect("certificate config should parse") + .server + .remove(0) +} + +fn epsk_server() -> tacacsrs_config::TacacsPlusServer { + parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "epsk-server", + "server-type": "accounting", + "address": "10.0.5.2", + "port": 49, + "client-identity": { + "tls13-epsk": { + "central-keystore-reference": "secret-reference-epsk", + "external-identity": "client@example.test" + } + } + }] + } + }"#, + ) + .expect("EPSK config should parse") + .server + .remove(0) +} + +fn certificate_material() -> ResolvedCredential { + ResolvedCredential::CertificateWithKey(CertificateWithKeyMaterial { + certificate: PublicBytes::new(b"certificate".to_vec()), + private_key: SecretBytes::new(b"private-key".to_vec()), + }) +} + +fn ca_material() -> ResolvedCredential { + ResolvedCredential::CaCertificateBag(CertificateBagMaterial { + certificates: vec![PublicBytes::new(b"ca".to_vec())], + }) +} + +fn ee_material() -> ResolvedCredential { + ResolvedCredential::EeCertificateBag(CertificateBagMaterial { + certificates: vec![PublicBytes::new(b"ee".to_vec())], + }) +} + +#[tokio::test] +async fn fake_resolver_completes_all_request_variants() { + let certificate_plan = + ResolutionPlan::from_server(&certificate_and_trust_server()).expect("certificate plan"); + assert_eq!( + certificate_plan + .requests() + .iter() + .map(tacacsrs_credential_resolution::CredentialRequest::kind) + .collect::>(), + [ + CredentialKind::CertificateWithKey, + CredentialKind::CaCertificateBag, + CredentialKind::EeCertificateBag, + ] + ); + let certificate_resolver = FakeCredentialResolver::new() + .with_response(certificate_plan.requests()[0].slot(), certificate_material()) + .with_response(certificate_plan.requests()[1].slot(), ca_material()) + .with_response(certificate_plan.requests()[2].slot(), ee_material()); + let certificate_set = resolve_plan(&certificate_plan, &certificate_resolver) + .await + .expect("certificate plan should resolve"); + assert_eq!(certificate_set.len(), 3); + assert_eq!( + certificate_set + .credential(certificate_plan.requests()[0].slot()) + .expect("certificate material") + .kind(), + CredentialKind::CertificateWithKey, + ); + + let epsk_plan = ResolutionPlan::from_server(&epsk_server()).expect("EPSK plan"); + let epsk_resolver = FakeCredentialResolver::new().with_response( + epsk_plan.requests()[0].slot(), + ResolvedCredential::SymmetricKey(SecretBytes::new(b"epsk".to_vec())), + ); + let epsk_set = resolve_plan(&epsk_plan, &epsk_resolver) + .await + .expect("EPSK plan should resolve"); + assert_eq!( + epsk_set + .credential(epsk_plan.requests()[0].slot()) + .expect("EPSK material") + .kind(), + CredentialKind::SymmetricKey, + ); +} + +#[test] +fn request_plan_is_deterministic_explicit_and_redacted() { + let plan = ResolutionPlan::from_server(&certificate_and_trust_server()).expect("plan"); + let first = &plan.requests()[0]; + assert_eq!(first.context().server_name(), "certificate-server"); + assert_eq!(first.context().field_path(), "client-identity/certificate"); + assert_eq!( + first.reference().certificate_with_key(), + Some((Some("secret-reference-key"), Some("secret-reference-certificate"),)) + ); + + let debug = format!("{plan:?} {first:?} {:?}", first.reference()); + assert!(debug.contains("")); + for forbidden in [ + "secret-reference-key", + "secret-reference-certificate", + "secret-reference-ca", + "10.0.5.1", + ] { + assert!(!debug.contains(forbidden), "debug exposed {forbidden}"); + } +} + +#[test] +fn planning_requires_enumeration_and_rejects_incomplete_request() { + let raw = pipeline::parse_root_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [{ + "id": "local-secret-id", + "certificate": { + "central-keystore-reference": {"asymmetric-key": "central-key"} + } + }], + "server": [{ + "name": "bundled", + "server-type": "accounting", + "address": "10.0.5.3", + "port": 49, + "client-identity": {"credentials-reference": "local-secret-id"} + }] + } + }"#, + ) + .expect("raw bundle config"); + let error = ResolutionPlan::from_server(&raw.tacacs_plus.server[0]) + .expect_err("raw local reference should fail"); + assert_eq!(error.kind(), ResolutionErrorKind::EnumerationRequired); + assert!(error.to_string().contains("credentials-reference")); + assert!(!error.to_string().contains("local-secret-id")); + + let enumerated = enumerate_servers(&raw.tacacs_plus).expect("enumerate bundle"); + ResolutionPlan::from_server(&enumerated[0]).expect("enumerated plan"); + + let incomplete = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "server": [{ + "name": "incomplete", + "server-type": "accounting", + "address": "10.0.5.4", + "port": 49, + "client-identity": { + "certificate": {"central-keystore-reference": {}} + } + }] + } + }"#, + ) + .expect("generated central container is structurally valid"); + let error = ResolutionPlan::from_server(&incomplete.server[0]) + .expect_err("incomplete central request should fail planning"); + assert_eq!(error.kind(), ResolutionErrorKind::IncompleteRequest); + assert_eq!(error.expected(), Some(CredentialKind::CertificateWithKey)); +} + +#[test] +fn resolution_plan_covers_all_variants_after_bundle_enumeration() { + let config = parse_yang_json( + r#"{ + "ietf-system-tacacs-plus:tacacs-plus": { + "client-credentials": [ + { + "id": "certificate-bundle", + "certificate": { + "central-keystore-reference": { + "asymmetric-key": "../opaque bundle key", + "certificate": "opaque bundle certificate" + } + } + }, + { + "id": "epsk-bundle", + "tls13-epsk": { + "central-keystore-reference": "opaque bundle symmetric key", + "external-identity": "bundle@example.test" + } + } + ], + "server-credentials": [{ + "id": "trust-bundle", + "ca-certs": {"central-truststore-reference": "opaque bundle CA"}, + "ee-certs": {"central-truststore-reference": "opaque bundle EE"} + }], + "server": [ + { + "name": "certificate-server", + "server-type": "accounting", + "address": "10.0.5.5", + "port": 49, + "client-identity": {"credentials-reference": "certificate-bundle"}, + "server-authentication": {"credentials-reference": "trust-bundle"} + }, + { + "name": "epsk-server", + "server-type": "accounting", + "address": "10.0.5.6", + "port": 49, + "client-identity": {"credentials-reference": "epsk-bundle"} + } + ] + } + }"#, + ) + .expect("bundle config should parse"); + let servers = enumerate_servers(&config).expect("bundles should enumerate"); + let certificate_plan = ResolutionPlan::from_server(&servers[0]).expect("certificate plan"); + let epsk_plan = ResolutionPlan::from_server(&servers[1]).expect("EPSK plan"); + + assert_eq!( + certificate_plan + .requests() + .iter() + .map(tacacsrs_credential_resolution::CredentialRequest::kind) + .collect::>(), + [ + CredentialKind::CertificateWithKey, + CredentialKind::CaCertificateBag, + CredentialKind::EeCertificateBag, + ] + ); + assert_eq!( + certificate_plan.requests()[0] + .reference() + .certificate_with_key(), + Some((Some("../opaque bundle key"), Some("opaque bundle certificate"),)) + ); + assert_eq!( + certificate_plan.requests()[1].reference().certificate_bag(), + Some("opaque bundle CA") + ); + assert_eq!( + certificate_plan.requests()[2].reference().certificate_bag(), + Some("opaque bundle EE") + ); + assert_eq!(epsk_plan.requests().len(), 1); + assert_eq!(epsk_plan.requests()[0].kind(), CredentialKind::SymmetricKey); + assert_eq!( + epsk_plan.requests()[0].reference().symmetric_key(), + Some("opaque bundle symmetric key") + ); +} + +#[test] +fn result_set_rejects_missing_duplicate_unexpected_and_mismatched_responses() { + let plan = ResolutionPlan::from_server(&certificate_and_trust_server()).expect("plan"); + let slots = plan.requests(); + + let missing = ResolvedCredentialSet::from_responses( + &plan, + [ + ResolvedResponse::new(slots[0].slot(), certificate_material()), + ResolvedResponse::new(slots[1].slot(), ca_material()), + ], + ) + .expect_err("missing response should fail"); + assert_eq!(missing.kind(), ResolutionErrorKind::MissingResponse); + assert_eq!(missing.slot(), Some(slots[2].slot())); + + let duplicate = ResolvedCredentialSet::from_responses( + &plan, + [ + ResolvedResponse::new(slots[0].slot(), certificate_material()), + ResolvedResponse::new(slots[0].slot(), certificate_material()), + ], + ) + .expect_err("duplicate response should fail"); + assert_eq!(duplicate.kind(), ResolutionErrorKind::DuplicateResponse); + + let unexpected = ResolvedCredentialSet::from_responses( + &plan, + [ResolvedResponse::new( + tacacsrs_credential_resolution::RequestSlot::from_index(99), + certificate_material(), + )], + ) + .expect_err("unexpected slot should fail"); + assert_eq!(unexpected.kind(), ResolutionErrorKind::UnexpectedResponse); + + let mismatch = ResolvedCredentialSet::from_responses( + &plan, + [ + ResolvedResponse::new( + slots[0].slot(), + ResolvedCredential::SymmetricKey(SecretBytes::new(b"wrong-kind".to_vec())), + ), + ResolvedResponse::new(slots[1].slot(), ca_material()), + ResolvedResponse::new(slots[2].slot(), ee_material()), + ], + ) + .expect_err("wrong variant should fail"); + assert_eq!(mismatch.kind(), ResolutionErrorKind::ResponseMismatch); + assert_eq!(mismatch.expected(), Some(CredentialKind::CertificateWithKey)); + assert_eq!(mismatch.actual(), Some(CredentialKind::SymmetricKey)); +} + +#[tokio::test] +async fn provider_failures_are_typed_and_sanitized() { + let plan = ResolutionPlan::from_server(&epsk_server()).expect("EPSK plan"); + let resolver = FakeCredentialResolver::new() + .with_error(plan.requests()[0].slot(), ProviderErrorKind::AccessDenied); + + let error = resolve_plan(&plan, &resolver) + .await + .expect_err("provider error should propagate"); + assert_eq!(error.kind(), ResolutionErrorKind::AccessDenied); + assert!(error.to_string().contains("client-identity/tls13-epsk")); + assert!(!error.to_string().contains("secret-reference-epsk")); + assert!(!format!("{error:?}").contains("secret-reference-epsk")); +} + +#[test] +fn request_reference_variants_expose_only_matching_accessors() { + let certificate = CredentialReference::CertificateWithKey { + asymmetric_key: Some("key".to_owned()), + certificate: None, + }; + assert_eq!(certificate.certificate_with_key(), Some((Some("key"), None))); + assert_eq!(certificate.symmetric_key(), None); + assert_eq!(certificate.certificate_bag(), None); +} diff --git a/libraries/tacacsrs_networking/src/transport/tls_psk/config.rs b/libraries/tacacsrs_networking/src/transport/tls_psk/config.rs index 82bf664..47878c1 100644 --- a/libraries/tacacsrs_networking/src/transport/tls_psk/config.rs +++ b/libraries/tacacsrs_networking/src/transport/tls_psk/config.rs @@ -126,6 +126,7 @@ mod tests { key_format: None, cleartext_symmetric_key: Some(key.to_vec()), }), + central_keystore_reference: None, } } diff --git a/libraries/tacacsrs_networking/src/transport/tls_psk/context.rs b/libraries/tacacsrs_networking/src/transport/tls_psk/context.rs index 9b8b143..d4cb110 100644 --- a/libraries/tacacsrs_networking/src/transport/tls_psk/context.rs +++ b/libraries/tacacsrs_networking/src/transport/tls_psk/context.rs @@ -133,6 +133,7 @@ mod tests { key_format: None, cleartext_symmetric_key: Some(b"resolved-psk-bytes-with-enough-length".to_vec()), }), + central_keystore_reference: None, } } diff --git a/libraries/tacacsrs_networking/src/transport/tls_psk/from_server.rs b/libraries/tacacsrs_networking/src/transport/tls_psk/from_server.rs index 84c66e1..5f8174b 100644 --- a/libraries/tacacsrs_networking/src/transport/tls_psk/from_server.rs +++ b/libraries/tacacsrs_networking/src/transport/tls_psk/from_server.rs @@ -120,6 +120,7 @@ mod tests { key_format: None, cleartext_symmetric_key: Some(key.to_vec()), }), + central_keystore_reference: None, }), }); server