mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 19:16:17 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c3526bc4 | |||
| 99c3811d93 | |||
| 3a46baab13 | |||
| 81332718e6 | |||
| 0126f359e3 | |||
| 10603d0870 | |||
| 7f8a8cdbac | |||
| cc0254d8de | |||
| 1f23fd17b6 |
Generated
-2
@@ -9142,7 +9142,6 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"p256 0.13.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -9279,7 +9278,6 @@ dependencies = [
|
||||
"jiff",
|
||||
"metrics",
|
||||
"rmp-serde",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
|
||||
@@ -44,7 +44,6 @@ metrics = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
smallvec = { workspace = true }
|
||||
rmp-serde = { workspace = true }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, ReplicationConfiguration, ReplicationRuleStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
@@ -633,104 +632,6 @@ pub fn create_heal_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn lc_get_prefix(rule: &LifecycleRule) -> String {
|
||||
if let Some(p) = &rule.prefix {
|
||||
return p.to_string();
|
||||
} else if let Some(filter) = &rule.filter {
|
||||
if let Some(p) = &filter.prefix {
|
||||
return p.to_string();
|
||||
} else if let Some(and) = &filter.and
|
||||
&& let Some(p) = &and.prefix
|
||||
{
|
||||
return p.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
"".into()
|
||||
}
|
||||
|
||||
pub fn lc_has_active_rules(config: &BucketLifecycleConfiguration, prefix: &str) -> bool {
|
||||
if config.rules.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for rule in config.rules.iter() {
|
||||
if rule.status == ExpirationStatus::from_static(ExpirationStatus::DISABLED) {
|
||||
continue;
|
||||
}
|
||||
let rule_prefix = lc_get_prefix(rule);
|
||||
if !prefix.is_empty() && !rule_prefix.is_empty() && !prefix.starts_with(&rule_prefix) && !rule_prefix.starts_with(prefix)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(e) = &rule.noncurrent_version_expiration {
|
||||
if e.noncurrent_days.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(true) = e.newer_noncurrent_versions.map(|d| d > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if rule.noncurrent_version_transitions.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(true) = rule.expiration.as_ref().map(|e| e.date.is_some()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(true) = rule.expiration.as_ref().map(|e| e.days.is_some()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(Some(true)) = rule.expiration.as_ref().map(|e| e.expired_object_delete_marker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(true) = rule.transitions.as_ref().map(|t| !t.is_empty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if rule.transitions.is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn rep_has_active_rules(config: &ReplicationConfiguration, prefix: &str, recursive: bool) -> bool {
|
||||
if config.rules.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for rule in config.rules.iter() {
|
||||
if rule
|
||||
.status
|
||||
.eq(&ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !prefix.is_empty()
|
||||
&& let Some(filter) = &rule.filter
|
||||
&& let Some(r_prefix) = &filter.prefix
|
||||
&& !r_prefix.is_empty()
|
||||
{
|
||||
// incoming prefix must be in rule prefix
|
||||
if !recursive && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
// If recursive, we can skip this rule if it doesn't match the tested prefix or level below prefix
|
||||
// does not match
|
||||
if recursive && !r_prefix.starts_with(prefix) && !prefix.starts_with(r_prefix) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPriority>) -> Result<(), String> {
|
||||
let req = HealChannelRequest {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! E2E proof that a mid-stream GET failure is *reportable* — rustfs#4784.
|
||||
//!
|
||||
//! The functional invariant (a beyond-quorum read must fail rather than return
|
||||
//! a clean short body) is already covered by
|
||||
//! `degraded_read_eof_regression_test`. This suite covers the half that issue
|
||||
//! #4784 got stuck on for a month: whether an operator can tell, from the
|
||||
//! source server's log alone, that a GET failed mid-body and **which object**
|
||||
//! it failed on.
|
||||
//!
|
||||
//! The reporter saw only downstream symptoms — `rclone` reporting
|
||||
//! `unexpected EOF` on its PUT, and the receiving RustFS logging
|
||||
//! `Io error: error reading a body from connection` with a 500. In a cross-remote
|
||||
//! `rclone sync`, the source GET body *is* the destination PUT body, so a source
|
||||
//! read that ends short of its committed `Content-Length` surfaces as a PUT
|
||||
//! failure on the far side. Built-in replication and site replication have the
|
||||
//! same shape (read locally, PUT remotely), which is why every transport in that
|
||||
//! report failed the same way.
|
||||
//!
|
||||
//! The source side, meanwhile, said nothing:
|
||||
//! * `GetObjectReaderStream`'s short-read and read-error arms only incremented
|
||||
//! a metric; their log lines sat behind the `tracing-chunk-debug` cargo
|
||||
//! feature, which is not in the default feature set and therefore is not
|
||||
//! compiled into any released binary.
|
||||
//! * `GetObjectStreamingReader` did log mid-stream failures, but only under a
|
||||
//! `request_id` — with no bucket or object name, a failure could not be
|
||||
//! traced back to the object that caused it.
|
||||
//! * Those lines were `warn!`, while `DEFAULT_LOG_LEVEL` is `error`, so a
|
||||
//! default deployment filtered them out anyway.
|
||||
//!
|
||||
//! This test reproduces the source-side fault against a real server over the S3
|
||||
//! API and asserts the operator-visible evidence, at the **default** log level.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::chaos::DiskFaultHarness;
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
const MIB: usize = 1024 * 1024;
|
||||
const OP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// The structured event name every GET body failure is tagged with.
|
||||
const STREAM_BODY_EVENT: &str = "get_object_stream_body";
|
||||
|
||||
fn payload(len: usize, seed: u8) -> Vec<u8> {
|
||||
(0..len)
|
||||
.map(|i| (i as u64).wrapping_mul(2654435761).wrapping_add(seed as u64) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Upload a multipart object so the data lands in real `part.*` shard files
|
||||
/// rather than being inlined into `xl.meta` (inlined objects cannot be
|
||||
/// corrupted shard-wise, and never exercise the streaming read path).
|
||||
async fn put_multipart(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
parts: Vec<Vec<u8>>,
|
||||
) -> Result<usize, Box<dyn Error + Send + Sync>> {
|
||||
let total_len = parts.iter().map(Vec::len).sum();
|
||||
|
||||
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let mut completed = Vec::with_capacity(parts.len());
|
||||
for (index, part_body) in parts.into_iter().enumerate() {
|
||||
let part_number = (index + 1) as i32;
|
||||
let uploaded = timeout(
|
||||
OP_TIMEOUT,
|
||||
client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part_body))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("upload_part {part_number} timed out"))??;
|
||||
completed.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(uploaded.e_tag().ok_or("missing part etag")?)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
timeout(
|
||||
OP_TIMEOUT,
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "complete_multipart_upload timed out")??;
|
||||
|
||||
Ok(total_len)
|
||||
}
|
||||
|
||||
/// rustfs#4784: reproduce the source-side fault the reporter kept hitting —
|
||||
/// a GET that commits `200` + a full `Content-Length` and then cannot finish
|
||||
/// the body — and assert the server log names the object, at the log level a
|
||||
/// default deployment actually runs with.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn midstream_get_failure_is_logged_with_the_object_at_default_log_level() -> TestResult {
|
||||
init_logging();
|
||||
info!("rustfs#4784: a mid-stream GET failure must name its object in the source log");
|
||||
|
||||
let mut harness = DiskFaultHarness::new(4).await?;
|
||||
|
||||
// Capture the child's stdout so the test can read what an operator would.
|
||||
let log_path = format!("{}/server.log", harness.env.temp_dir);
|
||||
harness.env.capture_log_path = Some(log_path.clone());
|
||||
|
||||
// Reproduce a DEFAULT deployment's logging, not the e2e harness's
|
||||
// permissive `rustfs=info`: `DEFAULT_LOG_LEVEL` is `error`. Before the
|
||||
// #4784 fix these failures were `warn!`, so a default deployment
|
||||
// filtered them out entirely — which is why the reporter's source logs
|
||||
// were empty. extra_env is applied after the harness's own RUST_LOG, so
|
||||
// this wins.
|
||||
harness.set_env("RUST_LOG", "error");
|
||||
harness.set_env("RUSTFS_OBS_LOGGER_LEVEL", "error");
|
||||
|
||||
harness.start_server().await?;
|
||||
let client = harness.env.create_s3_client();
|
||||
|
||||
let bucket = "issue4784-source-read";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
// Named after the reporter's restic index objects, which is where they
|
||||
// saw the failures.
|
||||
let key = "index/3b18542ab3af4c3d03f804c7a24173e7836ef7fa447b5d1e9d634f975cc51611";
|
||||
let expected_len = put_multipart(
|
||||
&client,
|
||||
bucket,
|
||||
key,
|
||||
vec![payload(5 * MIB, 71), payload(5 * MIB, 72), payload(5 * MIB, 73)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Baseline: the object reads back completely before any corruption.
|
||||
let baseline = timeout(OP_TIMEOUT, client.get_object().bucket(bucket).key(key).send())
|
||||
.await
|
||||
.map_err(|_| "baseline GET timed out")??
|
||||
.body
|
||||
.collect()
|
||||
.await?;
|
||||
assert_eq!(baseline.into_bytes().len(), expected_len, "baseline GET must return the whole object");
|
||||
|
||||
// Corrupt three of four shards in a 2+2 set: below the 2-shard read
|
||||
// quorum. The corruption sits mid-file, so block 0 still reads clean —
|
||||
// the server commits 200 + the full Content-Length and only then cannot
|
||||
// reconstruct. That is the mid-stream window the reporter's downstream
|
||||
// saw as `unexpected EOF`.
|
||||
harness.corrupt_object_shard(0, bucket, key)?;
|
||||
harness.corrupt_object_shard(1, bucket, key)?;
|
||||
harness.corrupt_object_shard(2, bucket, key)?;
|
||||
|
||||
let response = timeout(OP_TIMEOUT, client.get_object().bucket(bucket).key(key).send())
|
||||
.await
|
||||
.map_err(|_| "degraded GET timed out")?;
|
||||
|
||||
// Either outcome is functionally correct (that invariant belongs to
|
||||
// degraded_read_eof_regression_test); this suite only needs the read to
|
||||
// have failed so there is something to report.
|
||||
let delivered = match response {
|
||||
Err(err) => {
|
||||
info!("degraded GET failed before the body: {err}");
|
||||
None
|
||||
}
|
||||
Ok(response) => match response.body.collect().await {
|
||||
Ok(aggregated) => Some(aggregated.into_bytes().len()),
|
||||
Err(err) => {
|
||||
info!("degraded GET failed mid-body as expected: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
assert_ne!(
|
||||
delivered,
|
||||
Some(expected_len),
|
||||
"the beyond-quorum read unexpectedly succeeded; this suite needs a failed read to have something to report"
|
||||
);
|
||||
|
||||
// Give the child a moment to flush its stdout.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let logged = std::fs::read_to_string(&log_path)?;
|
||||
|
||||
let failure_lines: Vec<&str> = logged.lines().filter(|line| line.contains(STREAM_BODY_EVENT)).collect();
|
||||
|
||||
assert!(
|
||||
!failure_lines.is_empty(),
|
||||
"a mid-stream GET failure produced no `{STREAM_BODY_EVENT}` line at the default log level. \
|
||||
This is the #4784 blind spot: the failure was only counted in a metric, or logged below \
|
||||
`error` and filtered out. Captured log:\n{logged}"
|
||||
);
|
||||
|
||||
// The identity is the whole point: a request_id alone cannot be resolved
|
||||
// back to an object once the request is over.
|
||||
assert!(
|
||||
failure_lines.iter().any(|line| line.contains(key)),
|
||||
"no `{STREAM_BODY_EVENT}` line named the failing object `{key}`, so the report is still \
|
||||
unactionable. Lines seen:\n{}",
|
||||
failure_lines.join("\n")
|
||||
);
|
||||
assert!(
|
||||
failure_lines.iter().any(|line| line.contains(bucket)),
|
||||
"no `{STREAM_BODY_EVENT}` line named the failing bucket `{bucket}`. Lines seen:\n{}",
|
||||
failure_lines.join("\n")
|
||||
);
|
||||
|
||||
info!(
|
||||
"source-side evidence now present: {} stream-body failure line(s) naming the object",
|
||||
failure_lines.len()
|
||||
);
|
||||
for line in &failure_lines {
|
||||
info!("operator-visible evidence: {line}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ mod replacement_privileged_e2e_test;
|
||||
#[cfg(test)]
|
||||
mod degraded_read_eof_regression_test;
|
||||
|
||||
// rustfs#4784: a mid-stream GET failure must be reportable from the source
|
||||
// server's log alone — naming the object, at the default log level.
|
||||
#[cfg(test)]
|
||||
mod get_stream_failure_observability_test;
|
||||
|
||||
// backlog#1183: GET codec-streaming fast path must be byte/header identical to
|
||||
// the legacy duplex path before its rollout gates can be flipped on by default.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1041,7 +1041,13 @@ fn should_count_decommission_version_complete(ignore: bool, cleanup_ignored: boo
|
||||
fn is_decommission_copy_cleanup_safe_error(err: &Error) -> bool {
|
||||
// DataMovementOverwriteErr only means source and destination pool resolved to
|
||||
// the same pool. Without a target equivalence check it is not cleanup-safe.
|
||||
is_err_object_not_found(err) || is_err_version_not_found(err)
|
||||
if is_err_object_not_found(err) || is_err_version_not_found(err) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A not-found surfacing from inside a data-movement stage is the same
|
||||
// condition once the wrapper is unwrapped (backlog#1827 T2).
|
||||
crate::data_movement::data_movement_stage_source(err).is_some_and(is_decommission_copy_cleanup_safe_error)
|
||||
}
|
||||
|
||||
fn is_decommission_target_capacity_error(err: &Error) -> bool {
|
||||
@@ -1049,6 +1055,13 @@ fn is_decommission_target_capacity_error(err: &Error) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A stage failure keeps the error it wrapped, so classify by type rather
|
||||
// than by the rendered message (backlog#1827 T2). The substring fallback
|
||||
// stays for errors that reached here through some other wrapper.
|
||||
if let Some(source) = crate::data_movement::data_movement_stage_source(err) {
|
||||
return is_decommission_target_capacity_error(source);
|
||||
}
|
||||
|
||||
let message = err.to_string();
|
||||
let disk_full = Error::DiskFull.to_string();
|
||||
let storage_full = Error::StorageFull.to_string();
|
||||
@@ -4427,6 +4440,36 @@ mod tests {
|
||||
assert!(is_decommission_target_capacity_error(&Error::StorageFull));
|
||||
}
|
||||
|
||||
/// The decommission loop classifies errors that came back through a
|
||||
/// data-movement stage wrapper. Before backlog#1827 T2 the wrapper flattened
|
||||
/// everything into `Error::other(String)`, so these two classifiers had to
|
||||
/// match on rendered text; now the wrapped error is recoverable by type.
|
||||
#[test]
|
||||
fn decommission_classifiers_see_through_a_stage_wrapper() {
|
||||
let wrap = |inner: Error| {
|
||||
crate::data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"put_object",
|
||||
"bucket-a",
|
||||
"object-a",
|
||||
inner,
|
||||
)
|
||||
};
|
||||
|
||||
// Capacity: the target pool filling up must still stop the loop.
|
||||
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
|
||||
assert!(is_decommission_target_capacity_error(&wrap(Error::StorageFull)));
|
||||
assert!(!is_decommission_target_capacity_error(&wrap(Error::SlowDown)));
|
||||
|
||||
// Cleanup safety: a not-found surfacing from inside a stage is the same
|
||||
// condition as one surfacing directly, so the source entry stays
|
||||
// eligible for cleanup.
|
||||
let not_found = Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string());
|
||||
assert!(is_decommission_copy_cleanup_safe_error(¬_found));
|
||||
assert!(is_decommission_copy_cleanup_safe_error(&wrap(not_found)));
|
||||
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
|
||||
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
|
||||
|
||||
@@ -471,8 +471,60 @@ fn resolve_data_movement_abort_result(
|
||||
))
|
||||
}
|
||||
|
||||
fn data_movement_stage_error(op_label: &str, stage: &str, bucket: &str, object: &str, err: impl std::fmt::Display) -> Error {
|
||||
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object}: {err}"))
|
||||
/// A data-movement stage failure that keeps the error it wrapped.
|
||||
///
|
||||
/// The rendered message is byte-identical to the `format!` this replaced, so
|
||||
/// logs and any message-matching callers are unaffected. What changes is that
|
||||
/// the original error stays reachable through `source()`, which is what lets
|
||||
/// the decommission loop classify by type instead of by substring
|
||||
/// (backlog#1827 T2).
|
||||
#[derive(Debug)]
|
||||
struct DataMovementStageError {
|
||||
rendered: String,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DataMovementStageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.rendered)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DataMovementStageError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
Some(self.source.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
fn data_movement_stage_error<E>(op_label: &str, stage: &str, bucket: &str, object: &str, err: E) -> Error
|
||||
where
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object}: {err}");
|
||||
Error::other(DataMovementStageError {
|
||||
rendered,
|
||||
source: Box::new(err),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn data_movement_stage_error_for_test(op_label: &str, stage: &str, bucket: &str, object: &str, err: Error) -> Error {
|
||||
data_movement_stage_error(op_label, stage, bucket, object, err)
|
||||
}
|
||||
|
||||
/// Recover the error a [`data_movement_stage_error`] wrapped, if this is one.
|
||||
///
|
||||
/// `Error::other` boxes through `std::io::Error`, so the chain is
|
||||
/// `StorageError::Io` -> `DataMovementStageError` -> the original error.
|
||||
pub(crate) fn data_movement_stage_source(err: &Error) -> Option<&Error> {
|
||||
let Error::Io(io_err) = err else {
|
||||
return None;
|
||||
};
|
||||
io_err
|
||||
.get_ref()?
|
||||
.downcast_ref::<DataMovementStageError>()?
|
||||
.source
|
||||
.downcast_ref::<Error>()
|
||||
}
|
||||
|
||||
fn schedule_data_movement_multipart_abort_cleanup(
|
||||
@@ -1865,6 +1917,40 @@ mod tests {
|
||||
assert!(message.contains(Error::SlowDown.to_string().as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_error_renders_exactly_as_the_format_it_replaced() {
|
||||
// The wrapper gained a source; its message must not have moved, or log
|
||||
// scrapers and any message-matching caller would break (backlog#1827 T2).
|
||||
// `Error::other` renders through `StorageError::Io`, which prefixes
|
||||
// "Io error: " — that was true of the `format!` this replaced too, so
|
||||
// the full string is what must stay stable.
|
||||
let err = data_movement_stage_error("rebalance_object", "put_object", "bucket-a", "object-a", Error::SlowDown);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("Io error: rebalance_object: put_object failed for bucket-a/object-a: {}", Error::SlowDown)
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
Error::other(format!("rebalance_object: put_object failed for bucket-a/object-a: {}", Error::SlowDown)).to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_error_keeps_the_wrapped_error_recoverable() {
|
||||
for original in [Error::DiskFull, Error::StorageFull, Error::FileNotFound, Error::SlowDown] {
|
||||
let wrapped =
|
||||
data_movement_stage_error("decommission_object", "put_object", "bucket-a", "object-a", original.clone());
|
||||
let recovered = data_movement_stage_source(&wrapped).expect("the wrapped error must be recoverable");
|
||||
assert_eq!(recovered.to_string(), original.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_source_ignores_errors_it_did_not_wrap() {
|
||||
assert!(data_movement_stage_source(&Error::DiskFull).is_none());
|
||||
assert!(data_movement_stage_source(&Error::other("plain io error")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_part_stage_error_includes_stage_object_and_part() {
|
||||
let err =
|
||||
|
||||
+178
-194
@@ -227,11 +227,11 @@ impl ForegroundPressure {
|
||||
struct CompletedHealStatus {
|
||||
heal_type: HealType,
|
||||
status: HealTaskStatus,
|
||||
result_items: Vec<HealResultItem>,
|
||||
result_items_truncated: bool,
|
||||
completed_at: SystemTime,
|
||||
/// Sequence-stamped retained window, archived with the completion so
|
||||
/// incremental consumers keep their cursor across the transition (HS-06).
|
||||
/// The un-stamped legacy view is derived from it on demand.
|
||||
seqed_items: Vec<(u64, HealResultItem)>,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
@@ -293,7 +293,7 @@ fn empty_task_report(status: HealTaskStatus) -> HealTaskReport {
|
||||
fn completed_task_report(completed: &CompletedHealStatus, since: Option<u64>) -> HealTaskReport {
|
||||
let mut lagged = false;
|
||||
let result_items = match since {
|
||||
None => completed.result_items.clone(),
|
||||
None => completed.seqed_items.iter().map(|(_, item)| item.clone()).collect(),
|
||||
Some(cursor) => {
|
||||
if cursor + 1 < completed.min_seq {
|
||||
lagged = true;
|
||||
@@ -1027,8 +1027,10 @@ pub struct HealManager {
|
||||
active_heals: Arc<Mutex<HashMap<String, Arc<HealTask>>>>,
|
||||
/// Heal queue (priority-based)
|
||||
heal_queue: Arc<Mutex<PriorityHealQueue>>,
|
||||
/// Recently completed heal statuses retained for status queries.
|
||||
completed_heals: Arc<Mutex<HashMap<String, CompletedHealStatus>>>,
|
||||
/// Recently completed heal statuses retained for status queries. Values
|
||||
/// are shared so the lookup helper can hand a completed entry to a
|
||||
/// caller without cloning the retained result window.
|
||||
completed_heals: Arc<Mutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
/// Client tokens merged into an existing task id.
|
||||
task_aliases: Arc<Mutex<HashMap<String, HealTaskAlias>>>,
|
||||
/// Heal tasks waiting for a retry backoff to expire.
|
||||
@@ -1051,10 +1053,21 @@ pub struct HealManager {
|
||||
workload_provider: Option<WorkloadSnapshotProviderRef>,
|
||||
}
|
||||
|
||||
/// Where a task-id lookup resolved. The variants carry the resolved state
|
||||
/// so both the status and the report adapters can consume one shared
|
||||
/// cascade without re-locking.
|
||||
enum TaskStateLookup {
|
||||
Active(Arc<HealTask>),
|
||||
Retrying(HealTaskStatus),
|
||||
Completed(Arc<CompletedHealStatus>),
|
||||
Queued,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
struct HealQueueContext<'a> {
|
||||
heal_queue: &'a Arc<Mutex<PriorityHealQueue>>,
|
||||
active_heals: &'a Arc<Mutex<HashMap<String, Arc<HealTask>>>>,
|
||||
completed_heals: &'a Arc<Mutex<HashMap<String, CompletedHealStatus>>>,
|
||||
completed_heals: &'a Arc<Mutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
|
||||
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||
config: &'a Arc<RwLock<HealConfig>>,
|
||||
@@ -2160,47 +2173,79 @@ impl HealManager {
|
||||
}
|
||||
|
||||
/// Get task status
|
||||
pub async fn get_task_status(&self, task_id: &str) -> Result<HealTaskStatus> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
/// Ordered task-state lookup shared by every status/report query. The
|
||||
/// map precedence mirrors the historical per-method cascades exactly:
|
||||
/// active, then retrying, then completed — where a completed entry in a
|
||||
/// retrying state outranks the queue so a retrying task reports
|
||||
/// Retrying, never Pending — then the queue, and finally a terminal
|
||||
/// completed entry. `heal_path` additionally constrains the map matches
|
||||
/// the way the `*_for_path` variants always have.
|
||||
async fn lookup_task_state(&self, canonical_task_id: &str, heal_path: Option<&str>) -> TaskStateLookup {
|
||||
let matches_path = |heal_type: &HealType| heal_path.is_none_or(|path| heal_type_matches_path(heal_type, path));
|
||||
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id) {
|
||||
return Ok(task.get_status().await);
|
||||
if let Some(task) = active_heals
|
||||
.get(canonical_task_id)
|
||||
.filter(|task| matches_path(&task.heal_type))
|
||||
{
|
||||
return TaskStateLookup::Active(Arc::clone(task));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||
return Ok(retrying.status());
|
||||
if let Some(retrying) = retrying_heals
|
||||
.get(canonical_task_id)
|
||||
.filter(|retrying| matches_path(&retrying.request.heal_type))
|
||||
{
|
||||
return TaskStateLookup::Retrying(retrying.status());
|
||||
}
|
||||
}
|
||||
|
||||
// One completed-map pass (single lock + prune): a retrying completion
|
||||
// returns immediately; a terminal completion is held back until the
|
||||
// queue has been checked, so queued work outranks it.
|
||||
let mut terminal_completed: Option<Arc<CompletedHealStatus>> = None;
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(canonical_task_id).filter(|c| matches_path(&c.heal_type)) {
|
||||
if completed_status_is_retrying(&completed.status) {
|
||||
return TaskStateLookup::Completed(Arc::clone(completed));
|
||||
}
|
||||
terminal_completed = Some(Arc::clone(completed));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(completed.status.clone());
|
||||
let queue = self.heal_queue.lock().await;
|
||||
let queued = match heal_path {
|
||||
Some(path) => queue.contains_request_id_matching_path(canonical_task_id, path),
|
||||
None => queue.contains_request_id(canonical_task_id),
|
||||
};
|
||||
if queued {
|
||||
return TaskStateLookup::Queued;
|
||||
}
|
||||
}
|
||||
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id(&canonical_task_id) {
|
||||
return Ok(HealTaskStatus::Pending);
|
||||
match terminal_completed {
|
||||
Some(completed) => TaskStateLookup::Completed(completed),
|
||||
None => TaskStateLookup::NotFound,
|
||||
}
|
||||
drop(queue);
|
||||
}
|
||||
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id) {
|
||||
return Ok(completed.status.clone());
|
||||
pub async fn get_task_status(&self, task_id: &str) -> Result<HealTaskStatus> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
match self.lookup_task_state(&canonical_task_id, None).await {
|
||||
TaskStateLookup::Active(task) => Ok(task.get_status().await),
|
||||
TaskStateLookup::Retrying(status) => Ok(status),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
|
||||
TaskStateLookup::Queued => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
}),
|
||||
}
|
||||
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_task_report(&self, task_id: &str) -> Result<HealTaskReport> {
|
||||
@@ -2212,46 +2257,15 @@ impl HealManager {
|
||||
/// full-snapshot semantics.
|
||||
pub async fn get_task_report_since(&self, task_id: &str, since: Option<u64>) -> Result<HealTaskReport> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id) {
|
||||
return Ok(active_task_report(task, since).await);
|
||||
}
|
||||
match self.lookup_task_state(&canonical_task_id, None).await {
|
||||
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
|
||||
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
|
||||
TaskStateLookup::Queued => Ok(empty_task_report(HealTaskStatus::Pending)),
|
||||
TaskStateLookup::NotFound => Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
}),
|
||||
}
|
||||
|
||||
{
|
||||
let retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||
return Ok(empty_task_report(retrying.status()));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id(&canonical_task_id) {
|
||||
return Ok(empty_task_report(HealTaskStatus::Pending));
|
||||
}
|
||||
}
|
||||
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id) {
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_task_report_for_path(&self, heal_path: &str, task_id: &str) -> Result<HealTaskReport> {
|
||||
@@ -2266,59 +2280,20 @@ impl HealManager {
|
||||
since: Option<u64>,
|
||||
) -> Result<HealTaskReport> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&task.heal_type, heal_path)
|
||||
{
|
||||
return Ok(active_task_report(task, since).await);
|
||||
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await {
|
||||
TaskStateLookup::Active(task) => Ok(active_task_report(&task, since).await),
|
||||
TaskStateLookup::Retrying(status) => Ok(empty_task_report(status)),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed_task_report(&completed, since)),
|
||||
TaskStateLookup::Queued => Ok(empty_task_report(HealTaskStatus::Pending)),
|
||||
TaskStateLookup::NotFound => {
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
}
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&retrying.request.heal_type, heal_path)
|
||||
{
|
||||
return Ok(empty_task_report(retrying.status()));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id_matching_path(&canonical_task_id, heal_path) {
|
||||
return Ok(empty_task_report(HealTaskStatus::Pending));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
{
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
}
|
||||
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get task status for a path-bound client token.
|
||||
@@ -2328,59 +2303,20 @@ impl HealManager {
|
||||
/// recently completed task, a different token is invalid for that path.
|
||||
pub async fn get_task_status_for_path(&self, heal_path: &str, task_id: &str) -> Result<HealTaskStatus> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&task.heal_type, heal_path)
|
||||
{
|
||||
return Ok(task.get_status().await);
|
||||
match self.lookup_task_state(&canonical_task_id, Some(heal_path)).await {
|
||||
TaskStateLookup::Active(task) => Ok(task.get_status().await),
|
||||
TaskStateLookup::Retrying(status) => Ok(status),
|
||||
TaskStateLookup::Completed(completed) => Ok(completed.status.clone()),
|
||||
TaskStateLookup::Queued => Ok(HealTaskStatus::Pending),
|
||||
TaskStateLookup::NotFound => {
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
}
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&retrying.request.heal_type, heal_path)
|
||||
{
|
||||
return Ok(retrying.status());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(completed.status.clone());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id_matching_path(&canonical_task_id, heal_path) {
|
||||
return Ok(HealTaskStatus::Pending);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
{
|
||||
return Ok(completed.status.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if self.path_has_task(heal_path).await {
|
||||
return Err(Error::InvalidClientToken);
|
||||
}
|
||||
|
||||
Err(Error::TaskNotFound {
|
||||
task_id: task_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn path_has_task(&self, heal_path: &str) -> bool {
|
||||
@@ -3503,20 +3439,23 @@ impl HealManager {
|
||||
completed_task.get_status().await
|
||||
};
|
||||
let completed_progress = completed_task.get_progress().await;
|
||||
let final_window = completed_task.get_result_items_since(None).await;
|
||||
// Single snapshot of the retained window: the task is
|
||||
// finished and already off the active map, so there is
|
||||
// no concurrent writer to race with.
|
||||
let seqed_items = completed_task.get_seqed_result_items().await;
|
||||
let (next_seq, min_seq) = completed_task.result_seq_cursors();
|
||||
let completed_status_entry = CompletedHealStatus {
|
||||
heal_type: completed_task.heal_type.clone(),
|
||||
status: completed_status.clone(),
|
||||
result_items: final_window.items.clone(),
|
||||
result_items_truncated: completed_task.result_items_truncated(),
|
||||
completed_at: SystemTime::now(),
|
||||
seqed_items: completed_task.get_seqed_result_items().await,
|
||||
next_seq: final_window.next_seq,
|
||||
min_seq: final_window.min_seq,
|
||||
seqed_items,
|
||||
next_seq,
|
||||
min_seq,
|
||||
};
|
||||
let mut completed_heals_guard = completed_heals_clone.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals_guard);
|
||||
completed_heals_guard.insert(task_id.clone(), completed_status_entry);
|
||||
completed_heals_guard.insert(task_id.clone(), Arc::new(completed_status_entry));
|
||||
// update statistics
|
||||
let mut stats = statistics_clone.write().await;
|
||||
match completed_status {
|
||||
@@ -3808,7 +3747,7 @@ fn heal_request_set_key_for_task(task: &HealTask) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_completed_heal_statuses(completed_heals: &mut HashMap<String, CompletedHealStatus>) {
|
||||
fn prune_completed_heal_statuses(completed_heals: &mut HashMap<String, Arc<CompletedHealStatus>>) {
|
||||
let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else {
|
||||
return;
|
||||
};
|
||||
@@ -5275,19 +5214,18 @@ mod tests {
|
||||
);
|
||||
manager.completed_heals.lock().await.insert(
|
||||
task_id,
|
||||
CompletedHealStatus {
|
||||
Arc::new(CompletedHealStatus {
|
||||
heal_type: request.heal_type,
|
||||
status: HealTaskStatus::Retrying {
|
||||
error: "Lock acquisition timeout".to_string(),
|
||||
retry_attempt: request.retry_attempts,
|
||||
},
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
cancel_token
|
||||
}
|
||||
@@ -5985,6 +5923,47 @@ mod tests {
|
||||
assert!(report.result_items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retrying_completion_outranks_the_queue_for_the_same_id() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = HealManager::new(storage, None);
|
||||
|
||||
// A completed entry recorded in a Retrying state for a task whose
|
||||
// request is also (still) queued under the same id: the retrying
|
||||
// completion must win the lookup, or the task would read back as
|
||||
// Pending while it is actually waiting out a retry backoff.
|
||||
let request = HealRequest::object("bucket".to_string(), "object".to_string(), None);
|
||||
let task_id = request.id.clone();
|
||||
manager.completed_heals.lock().await.insert(
|
||||
task_id.clone(),
|
||||
Arc::new(CompletedHealStatus {
|
||||
heal_type: request.heal_type.clone(),
|
||||
status: HealTaskStatus::Retrying {
|
||||
error: "transient disk failure".to_string(),
|
||||
retry_attempt: 1,
|
||||
},
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
}),
|
||||
);
|
||||
manager.heal_queue.lock().await.push(HealRequest {
|
||||
id: task_id.clone(),
|
||||
heal_type: request.heal_type,
|
||||
..request
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
manager.get_task_status(&task_id).await.expect("task must resolve"),
|
||||
HealTaskStatus::Retrying {
|
||||
error: "transient disk failure".to_string(),
|
||||
retry_attempt: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_task_status_reads_recent_completed_status() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
@@ -5992,18 +5971,17 @@ mod tests {
|
||||
|
||||
manager.completed_heals.lock().await.insert(
|
||||
"completed-token".to_string(),
|
||||
CompletedHealStatus {
|
||||
Arc::new(CompletedHealStatus {
|
||||
heal_type: HealType::Bucket {
|
||||
bucket: "bucket".to_string(),
|
||||
},
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -6022,25 +6000,27 @@ mod tests {
|
||||
|
||||
manager.completed_heals.lock().await.insert(
|
||||
"completed-token".to_string(),
|
||||
CompletedHealStatus {
|
||||
Arc::new(CompletedHealStatus {
|
||||
heal_type: HealType::Object {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
},
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items: vec![HealResultItem {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
object_size: 1024,
|
||||
..Default::default()
|
||||
}],
|
||||
result_items_truncated: true,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
seqed_items: vec![(
|
||||
1,
|
||||
HealResultItem {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
object_size: 1024,
|
||||
..Default::default()
|
||||
},
|
||||
)],
|
||||
next_seq: 2,
|
||||
min_seq: 1,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let report = manager
|
||||
@@ -6052,6 +6032,10 @@ mod tests {
|
||||
assert_eq!(report.status, HealTaskStatus::Completed);
|
||||
assert_eq!(report.result_items.len(), 1);
|
||||
assert_eq!(report.result_items[0].object_size, 1024);
|
||||
// The archived cursors pass through to the report so an incremental
|
||||
// consumer can resume against the next expected sequence.
|
||||
assert_eq!(report.next_seq, 2);
|
||||
assert_eq!(report.min_seq, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -29,6 +29,7 @@ use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
future::Future,
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -384,7 +385,7 @@ pub struct HealTask {
|
||||
/// monotonically increasing sequence number for incremental consumption
|
||||
/// (the client passes the last seen seq back and receives only newer
|
||||
/// items; see `get_result_items_since`).
|
||||
pub result_items: Arc<RwLock<Vec<(u64, HealResultItem)>>>,
|
||||
pub result_items: Arc<RwLock<VecDeque<(u64, HealResultItem)>>>,
|
||||
/// Next sequence number to assign; starts at 1.
|
||||
next_item_seq: Arc<AtomicU64>,
|
||||
/// Sequence number of the oldest item still inside the retention window;
|
||||
@@ -440,7 +441,7 @@ impl HealTask {
|
||||
replacement_resume_endpoint: None,
|
||||
status: Arc::new(RwLock::new(HealTaskStatus::Pending)),
|
||||
progress: Arc::new(RwLock::new(HealProgress::new())),
|
||||
result_items: Arc::new(RwLock::new(Vec::new())),
|
||||
result_items: Arc::new(RwLock::new(VecDeque::with_capacity(MAX_RETAINED_HEAL_RESULT_ITEMS))),
|
||||
next_item_seq: Arc::new(AtomicU64::new(1)),
|
||||
min_available_seq: Arc::new(AtomicU64::new(1)),
|
||||
result_items_truncated: Arc::new(AtomicBool::new(false)),
|
||||
@@ -931,7 +932,14 @@ impl HealTask {
|
||||
/// Sequence-stamped retained window, used when archiving a completed
|
||||
/// task so incremental cursors survive the transition (HS-06).
|
||||
pub async fn get_seqed_result_items(&self) -> Vec<(u64, HealResultItem)> {
|
||||
self.result_items.read().await.clone()
|
||||
self.result_items.read().await.iter().cloned().collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Sequence cursors of the retained window (next to assign, oldest
|
||||
/// retained) — the same pair `get_result_items_since` reports, without
|
||||
/// copying the items. Used when archiving a finished task.
|
||||
pub fn result_seq_cursors(&self) -> (u64, u64) {
|
||||
(self.next_item_seq.load(Ordering::Relaxed), self.min_available_seq.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Incremental result window (HS-06): `since = None` returns the full
|
||||
@@ -974,14 +982,14 @@ impl HealTask {
|
||||
let seq = self.next_item_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let mut result_items = self.result_items.write().await;
|
||||
if result_items.len() < MAX_RETAINED_HEAL_RESULT_ITEMS {
|
||||
result_items.push((seq, result));
|
||||
result_items.push_back((seq, result));
|
||||
} else {
|
||||
// Slide the window: the oldest item leaves and the cursor for the
|
||||
// oldest still-available item moves forward with it.
|
||||
result_items.remove(0);
|
||||
result_items.pop_front();
|
||||
self.min_available_seq
|
||||
.store(result_items.first().map_or(seq, |(oldest, _)| *oldest), Ordering::Relaxed);
|
||||
result_items.push((seq, result));
|
||||
.store(result_items.front().map_or(seq, |(oldest, _)| *oldest), Ordering::Relaxed);
|
||||
result_items.push_back((seq, result));
|
||||
self.result_items_truncated.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +368,10 @@ impl AdminClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cluster-aggregated background heal status.
|
||||
/// Cluster-aggregated background heal status. The route is registered
|
||||
/// POST-only on the server, so this must not go out as a GET.
|
||||
pub async fn background_heal_status(&self) -> Result<BackgroundHealStatus, AdminClientError> {
|
||||
self.get_json("/v3/background-heal/status").await
|
||||
self.post_json("/v3/background-heal/status", &[], Vec::new()).await
|
||||
}
|
||||
|
||||
/// Data scanner status (enabled state, freshness, runtime config).
|
||||
@@ -698,6 +699,21 @@ mod tests {
|
||||
assert!(!request.query.contains("clientToken"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_heal_status_posts_to_the_registered_route() {
|
||||
let body = r#"{"state":"idle","healQueueLength":0,"healActiveTasks":0,"clusterStatusComplete":true}"#;
|
||||
let server = TestServer::spawn(body, 200).await;
|
||||
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
|
||||
|
||||
let status = client.background_heal_status().await.expect("status decodes");
|
||||
assert_eq!(status.state, "idle");
|
||||
let request = server.recorded();
|
||||
// The server registers this route POST-only; a GET here answers 405.
|
||||
assert_eq!(request.method, "POST");
|
||||
assert_eq!(request.path, "/rustfs/admin/v3/background-heal/status");
|
||||
assert_eq!(request.query, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_error_status_maps_to_a_typed_error_with_body() {
|
||||
let server = TestServer::spawn(r#"{"code":"AccessDenied","message":"denied"}"#, 403).await;
|
||||
|
||||
@@ -26,6 +26,8 @@ use http::HeaderMap;
|
||||
use rustfs_config::server_config::{Config as ServerConfig, get_global_server_config as config_get_global_server_config};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use storage_api::owner::{
|
||||
ECSTORE_BUCKET_META_PREFIX, ECSTORE_RUSTFS_META_BUCKET, ECSTORE_STORAGE_FORMAT_FILE, ECSTORE_STORAGECLASS_RRS,
|
||||
ECSTORE_STORAGECLASS_STANDARD, ECSTORE_TRANSITION_COMPLETE, EcstoreBucketTargetSys, EcstoreBucketVersioningSys, EcstoreDisk,
|
||||
@@ -33,7 +35,7 @@ use storage_api::owner::{
|
||||
EcstoreDiskResult, EcstoreErrorType, EcstoreEvaluator, EcstoreEvent, EcstoreLcEventSrc, EcstoreLifecycle,
|
||||
EcstoreListPathRawOptions, EcstoreNsScannerOpenRequest, EcstoreObjectOpts, EcstoreReplicationConfigurationExt,
|
||||
EcstoreReplicationScannerBridge, EcstoreResultType, EcstoreScanGuard, EcstoreSetDisks, EcstoreStorageError, EcstoreStore,
|
||||
EcstoreTierConfig, EcstoreVersioningApi, HTTPPreconditions, HTTPRangeSpec, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
EcstoreVersioningApi, HTTPPreconditions, HTTPRangeSpec, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
ScannerReplicationHealObject, ScannerReplicationHealResult, ScannerReplicationQueueAdmission, ecstore_apply_expiry_rule,
|
||||
ecstore_apply_transition_rule, ecstore_expiry_state_handle, ecstore_get_global_tier_config_mgr, ecstore_get_lifecycle_config,
|
||||
ecstore_get_object_lock_config, ecstore_get_replication_config, ecstore_invalidate_admin_data_usage_snapshot_cache,
|
||||
@@ -363,8 +365,46 @@ pub(crate) fn resolve_scanner_server_config() -> Option<ServerConfig> {
|
||||
config_get_global_server_config()
|
||||
}
|
||||
|
||||
pub(crate) async fn list_runtime_tiers() -> Vec<EcstoreTierConfig> {
|
||||
ecstore_get_global_tier_config_mgr().read().await.list_tiers()
|
||||
/// How long the scanner caches the runtime tier-name list before re-reading
|
||||
/// the tier configuration manager.
|
||||
const TIER_NAME_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Process-wide TTL cache of runtime tier names.
|
||||
///
|
||||
/// The scan hot path only needs tier *names* to seed `SizeSummary::tier_stats`
|
||||
/// per object, but every `list_tiers()` call clones each full `TierConfig`
|
||||
/// (endpoints, credentials, prefixes) from the global manager. Caching just
|
||||
/// the names keeps the per-object cost at an `Arc` clone.
|
||||
///
|
||||
/// Staleness bounds: a newly added tier starts showing up in scans at most
|
||||
/// `TIER_NAME_CACHE_TTL` later; a removed tier can leave an all-zero
|
||||
/// `TierStats` seed behind for one cache generation, which merges harmlessly
|
||||
/// by key in per-object accounting and disappears on the next refresh.
|
||||
static TIER_NAME_CACHE: RwLock<Option<(Instant, Arc<[String]>)>> = RwLock::new(None);
|
||||
|
||||
/// Tier names currently registered in the tier configuration, cached for
|
||||
/// `TIER_NAME_CACHE_TTL`.
|
||||
pub(crate) async fn runtime_tier_names() -> Arc<[String]> {
|
||||
{
|
||||
let cached = TIER_NAME_CACHE.read().unwrap_or_else(|err| err.into_inner()).clone();
|
||||
if let Some((refreshed_at, names)) = cached
|
||||
&& refreshed_at.elapsed() < TIER_NAME_CACHE_TTL
|
||||
{
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
let tiers = ecstore_get_global_tier_config_mgr().read().await.list_tiers();
|
||||
let names: Arc<[String]> = tiers.iter().map(|tier| tier.name.clone()).collect::<Vec<_>>().into();
|
||||
*TIER_NAME_CACHE.write().unwrap_or_else(|err| err.into_inner()) = Some((Instant::now(), Arc::clone(&names)));
|
||||
names
|
||||
}
|
||||
|
||||
/// Test-only cache reset; the production cache has no invalidation hook
|
||||
/// because the TTL is its only refresh path.
|
||||
#[cfg(test)]
|
||||
fn reset_tier_name_cache_for_test() {
|
||||
*TIER_NAME_CACHE.write().unwrap_or_else(|err| err.into_inner()) = None;
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_runtime_free_version(oi: ScannerObjectInfo) {
|
||||
@@ -561,6 +601,20 @@ mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn runtime_tier_names_serves_cached_arc_within_ttl() {
|
||||
reset_tier_name_cache_for_test();
|
||||
// The tier config manager is unconfigured in unit tests, so the
|
||||
// first call populates the cache from an empty tier list...
|
||||
let first = runtime_tier_names().await;
|
||||
assert!(first.is_empty());
|
||||
// ...and a second call within the TTL must return the cached Arc
|
||||
// (pointer-equal) without re-reading the manager.
|
||||
let second = runtime_tier_names().await;
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn foreground_read_guard_tracks_stream_lifetime() {
|
||||
|
||||
@@ -45,7 +45,7 @@ use rustfs_common::metrics::{
|
||||
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit, trace_subscriber_count};
|
||||
use rustfs_filemeta::{MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, VersioningConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -53,10 +53,10 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::{
|
||||
BucketVersioningSys, Disk, DiskError, DiskInfoOptions, Evaluator, Event, LcEventSrc, ListPathRawOptions, ObjectOpts,
|
||||
ReplicationConfig, ReplicationHealObject, ReplicationQueueAdmission, ReplicationStatusType, STORAGE_FORMAT_FILE,
|
||||
ScannerDiskExt as _, ScannerLifecycleConfigExt as _, ScannerVersioningConfigExt as _, StorageError, apply_expiry_rule,
|
||||
apply_transition_rule, enqueue_runtime_newer_noncurrent, is_reserved_or_invalid_bucket, list_path_raw, path2_bucket_object,
|
||||
Disk, DiskError, DiskInfoOptions, Evaluator, Event, LcEventSrc, ListPathRawOptions, ObjectOpts, ReplicationConfig,
|
||||
ReplicationHealObject, ReplicationQueueAdmission, ReplicationStatusType, STORAGE_FORMAT_FILE, ScannerDiskExt as _,
|
||||
ScannerLifecycleConfigExt as _, ScannerVersioningConfigExt as _, StorageError, apply_expiry_rule, apply_transition_rule,
|
||||
enqueue_runtime_newer_noncurrent, is_reserved_or_invalid_bucket, list_path_raw, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path, queue_replication_heal, scanner_is_erasure,
|
||||
scanner_replication_config_for_lifecycle_eval,
|
||||
};
|
||||
@@ -934,6 +934,7 @@ impl ScannerItem {
|
||||
&mut self,
|
||||
object_infos: Vec<ObjectInfo>,
|
||||
lock_retention: Option<Arc<ObjectLockConfiguration>>,
|
||||
versioning_config: VersioningConfiguration,
|
||||
size_summary: &mut SizeSummary,
|
||||
) {
|
||||
if object_infos.is_empty() {
|
||||
@@ -958,21 +959,8 @@ impl ScannerItem {
|
||||
"Scanner lifecycle evaluation started"
|
||||
);
|
||||
|
||||
let versioning_config = match BucketVersioningSys::get(&self.bucket).await {
|
||||
Ok(versioning_config) => versioning_config,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_LIFECYCLE_ACTION,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %self.bucket,
|
||||
state = "versioning_lookup_failed_defaulting",
|
||||
"Scanner lifecycle action falling back to default bucket versioning"
|
||||
);
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
// `versioning_config` is resolved once per object by the caller
|
||||
// (`get_size`) and handed in; only `prefix_enabled` is consulted here.
|
||||
|
||||
let Some(lifecycle) = self.lifecycle.as_ref() else {
|
||||
let mut cumulative_size = 0;
|
||||
@@ -1402,6 +1390,11 @@ impl ScannerItem {
|
||||
fn alert_excessive_versions(&self, remaining_versions: usize, cumulative_size: i64) {
|
||||
ensure_scanner_alert_metrics_registered();
|
||||
let (too_many_versions, too_large_versions) = should_alert_excessive_versions(remaining_versions, cumulative_size);
|
||||
// Threshold check first so healthy objects never pay for the
|
||||
// object-path allocation below.
|
||||
if !too_many_versions && !too_large_versions {
|
||||
return;
|
||||
}
|
||||
let object_path = self.object_path();
|
||||
if too_many_versions {
|
||||
global_metrics().record_scanner_source_executed(ScannerWorkSource::Alerts, 1);
|
||||
|
||||
@@ -32,7 +32,9 @@ use rustfs_data_usage::{BucketTargetUsageInfo, BucketUsageInfo};
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_lock::{LockError, NamespaceLockGuard};
|
||||
use rustfs_utils::path::path_join_buf;
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
@@ -55,7 +57,7 @@ use crate::{
|
||||
BucketTargetSys, BucketVersioningSys, Disk, DiskError, ECStore, EcstoreError as Error, EcstoreResult as Result,
|
||||
RUSTFS_META_BUCKET, ReplicationConfig, STORAGE_FORMAT_FILE, ScannerDiskExt as _, ScannerLifecycleConfigExt as _,
|
||||
ScannerReplicationConfigExt as _, ScannerVersioningConfigExt as _, SetDisks, StorageError, enqueue_runtime_free_version,
|
||||
get_lifecycle_config, get_object_lock_config, get_replication_config, list_runtime_tiers, storageclass,
|
||||
get_lifecycle_config, get_object_lock_config, get_replication_config, runtime_tier_names, storageclass,
|
||||
};
|
||||
|
||||
pub(crate) const SCANNER_SKIP_FILE_ERROR: &str = "skip file";
|
||||
@@ -63,6 +65,11 @@ pub(crate) const SCANNER_METADATA_CORRUPT_ERROR: &str = "scanner metadata corrup
|
||||
pub(crate) const SCANNER_METADATA_TRANSIENT_ERROR: &str = "scanner metadata transient";
|
||||
const LOG_COMPONENT_SCANNER: &str = "scanner";
|
||||
const LOG_SUBSYSTEM_IO: &str = "io";
|
||||
// Mirrors `scanner_folder.rs` so the versioning-lookup fallback warn keeps its
|
||||
// historical `rustfs::scanner::folder` lifecycle event identity after the
|
||||
// lookup moved into `get_size`.
|
||||
const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
||||
const EVENT_SCANNER_LIFECYCLE_ACTION: &str = "scanner_lifecycle_action";
|
||||
const EVENT_SCANNER_DISK_BUCKET_STATE: &str = "scanner_disk_bucket_state";
|
||||
const EVENT_SCANNER_DATA_USAGE_STREAM: &str = "scanner_data_usage_stream";
|
||||
const EVENT_SCANNER_CACHE_PERSIST_STATE: &str = "scanner_cache_persist_state";
|
||||
@@ -3822,6 +3829,24 @@ impl ScannerIOCache for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed [`SizeSummary::tier_stats`] from the cached tier-name list.
|
||||
///
|
||||
/// Preserves the original seeding semantics: with no tiers configured the map
|
||||
/// stays completely empty (STANDARD/RRS are not seeded either); otherwise the
|
||||
/// standard storage classes are seeded alongside every configured tier so
|
||||
/// per-object accounting always finds its tier key.
|
||||
fn tier_stats_template(tier_names: &[String]) -> HashMap<String, TierStats> {
|
||||
let mut tier_stats = HashMap::with_capacity(tier_names.len() + 2);
|
||||
for tier_name in tier_names {
|
||||
tier_stats.insert(tier_name.clone(), TierStats::default());
|
||||
}
|
||||
if !tier_stats.is_empty() {
|
||||
tier_stats.insert(storageclass::STANDARD.to_string(), TierStats::default());
|
||||
tier_stats.insert(storageclass::RRS.to_string(), TierStats::default());
|
||||
}
|
||||
tier_stats
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ScannerIODisk for Disk {
|
||||
async fn get_size(&self, mut item: ScannerItem) -> Result<SizeSummary> {
|
||||
@@ -3861,10 +3886,26 @@ impl ScannerIODisk for Disk {
|
||||
}
|
||||
};
|
||||
|
||||
let versioned = BucketVersioningSys::get(&item.bucket)
|
||||
.await
|
||||
.map(|v| v.versioned(&item.object_path()))
|
||||
.unwrap_or(false);
|
||||
// Single versioning lookup per object, shared with `apply_actions`
|
||||
// (which used to query it a second time). On failure keep the
|
||||
// historical fallback: default configuration (versioned = false) plus
|
||||
// the warn that `apply_actions` used to emit.
|
||||
let versioning_config = match BucketVersioningSys::get(&item.bucket).await {
|
||||
Ok(versioning_config) => versioning_config,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
target: "rustfs::scanner::folder",
|
||||
event = EVENT_SCANNER_LIFECYCLE_ACTION,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %item.bucket,
|
||||
state = "versioning_lookup_failed_defaulting",
|
||||
"Scanner lifecycle action falling back to default bucket versioning"
|
||||
);
|
||||
VersioningConfiguration::default()
|
||||
}
|
||||
};
|
||||
let versioned = versioning_config.versioned(&item.object_path());
|
||||
|
||||
let object_infos = fivs
|
||||
.versions
|
||||
@@ -3879,19 +3920,10 @@ impl ScannerIODisk for Disk {
|
||||
|
||||
let mut size_summary = SizeSummary::default();
|
||||
|
||||
let tiers = list_runtime_tiers().await;
|
||||
|
||||
for tier in tiers.iter() {
|
||||
size_summary.tier_stats.insert(tier.name.clone(), TierStats::default());
|
||||
}
|
||||
if !size_summary.tier_stats.is_empty() {
|
||||
size_summary
|
||||
.tier_stats
|
||||
.insert(storageclass::STANDARD.to_string(), TierStats::default());
|
||||
size_summary
|
||||
.tier_stats
|
||||
.insert(storageclass::RRS.to_string(), TierStats::default());
|
||||
}
|
||||
// Tier names come from the process-wide TTL cache; seeding from them
|
||||
// replaces the per-object clone of every full TierConfig.
|
||||
let tier_names = runtime_tier_names().await;
|
||||
size_summary.tier_stats = tier_stats_template(&tier_names);
|
||||
|
||||
let lock_config = object_lock_config_for_scanner_item(&item).await;
|
||||
|
||||
@@ -3901,7 +3933,8 @@ impl ScannerIODisk for Disk {
|
||||
// `object_infos`.
|
||||
global_metrics().record_scanner_versions_scanned(object_infos.len() as u64);
|
||||
|
||||
item.apply_actions(object_infos, lock_config, &mut size_summary).await;
|
||||
item.apply_actions(object_infos, lock_config, versioning_config, &mut size_summary)
|
||||
.await;
|
||||
|
||||
if !free_version_infos.is_empty() {
|
||||
for oi in free_version_infos {
|
||||
@@ -4968,6 +5001,23 @@ mod tests {
|
||||
assert!(is_xl_meta_path("/data/bucket/object/xl.meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_stats_template_seeds_tiers_and_standard_classes() {
|
||||
let template = tier_stats_template(&["WARM".to_string(), "COLD".to_string()]);
|
||||
|
||||
assert_eq!(template.len(), 4);
|
||||
for tier in ["WARM", "COLD", storageclass::STANDARD, storageclass::RRS] {
|
||||
assert_eq!(template.get(tier), Some(&TierStats::default()), "missing seed for tier {tier}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_stats_template_stays_empty_without_tiers() {
|
||||
let template = tier_stats_template(&[]);
|
||||
|
||||
assert!(template.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_size_treats_missing_metadata_as_skip_file() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("rustfs-scanner-missing-meta-{}", Uuid::new_v4()));
|
||||
|
||||
@@ -99,7 +99,6 @@ pub(crate) use rustfs_ecstore::api::set_disk::SetDisks as EcstoreSetDisks;
|
||||
pub(crate) use rustfs_ecstore::api::storage::ECStore as EcstoreStore;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::storage::init_local_disks_with_instance_ctx as ecstore_init_local_disks_with_instance_ctx;
|
||||
pub(crate) use rustfs_ecstore::api::tier::tier_config::TierConfig as EcstoreTierConfig;
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
pub(crate) mod owner {
|
||||
@@ -114,15 +113,15 @@ pub(crate) mod owner {
|
||||
EcstoreDiskLocation, EcstoreDiskResult, EcstoreErrorType, EcstoreEvaluator, EcstoreEvent, EcstoreEventArgs,
|
||||
EcstoreLcEventSrc, EcstoreLifecycle, EcstoreListPathRawOptions, EcstoreNsScannerOpenRequest, EcstoreObjectOpts,
|
||||
EcstoreReplicationConfigurationExt, EcstoreReplicationScannerBridge, EcstoreResultType, EcstoreScanGuard,
|
||||
EcstoreSetDisks, EcstoreStorageError, EcstoreStore, EcstoreTierConfig, EcstoreVersioningApi,
|
||||
ScannerReplicationHealObject, ScannerReplicationHealResult, ScannerReplicationQueueAdmission, ecstore_apply_expiry_rule,
|
||||
ecstore_apply_transition_rule, ecstore_expiry_state_handle, ecstore_get_global_tier_config_mgr,
|
||||
ecstore_get_lifecycle_config, ecstore_get_object_lock_config, ecstore_get_replication_config,
|
||||
ecstore_invalidate_admin_data_usage_snapshot_cache, ecstore_invalidate_data_usage_snapshot_cache, ecstore_is_erasure,
|
||||
ecstore_is_erasure_sd, ecstore_is_reserved_or_invalid_bucket, ecstore_list_path_raw,
|
||||
ecstore_object_opts_from_object_info, ecstore_path2_bucket_object, ecstore_path2_bucket_object_with_base_path,
|
||||
ecstore_read_config, ecstore_replace_bucket_usage_memory_from_info, ecstore_resolve_object_store_handle,
|
||||
ecstore_save_config, ecstore_send_event, scanner_replication_config_for_lifecycle_eval,
|
||||
EcstoreSetDisks, EcstoreStorageError, EcstoreStore, EcstoreVersioningApi, ScannerReplicationHealObject,
|
||||
ScannerReplicationHealResult, ScannerReplicationQueueAdmission, ecstore_apply_expiry_rule, ecstore_apply_transition_rule,
|
||||
ecstore_expiry_state_handle, ecstore_get_global_tier_config_mgr, ecstore_get_lifecycle_config,
|
||||
ecstore_get_object_lock_config, ecstore_get_replication_config, ecstore_invalidate_admin_data_usage_snapshot_cache,
|
||||
ecstore_invalidate_data_usage_snapshot_cache, ecstore_is_erasure, ecstore_is_erasure_sd,
|
||||
ecstore_is_reserved_or_invalid_bucket, ecstore_list_path_raw, ecstore_object_opts_from_object_info,
|
||||
ecstore_path2_bucket_object, ecstore_path2_bucket_object_with_base_path, ecstore_read_config,
|
||||
ecstore_replace_bucket_usage_memory_from_info, ecstore_resolve_object_store_handle, ecstore_save_config,
|
||||
ecstore_send_event, scanner_replication_config_for_lifecycle_eval,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
3d602080f7ca4c32ba9e37ad1a32665c78560726b30aeee08fd9e95eb2f36194 accept-vectors.json
|
||||
d3c19946288717088145592e0e8d6f2fa684443ba2f73d4c7bc49c415d6dd051 certificate-profile.json
|
||||
060485263c51003274c056a0e04bec1b7d76157cf599ba79eebe040bc7cee71b error-codes.json
|
||||
43fe297ffb512b1b9f4af62f1832f3aa3905157893bfdc3dcc6d56f5a98aaef6 reject-vectors.json
|
||||
b946175b094f4a8d75091b652fbe3d4327c9c795f28e02c96e1ab90a429e418d surface-separation.json
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "auth",
|
||||
"fixture": "accept-vectors",
|
||||
"description": "Presented certificates that authenticate. Time offsets are seconds relative to the moment the request is evaluated.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "current credential on an active cluster",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "first device authenticates while its cluster is still pending",
|
||||
"clusterState": "PENDING",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -60,
|
||||
"validUntilOffsetSeconds": 86340,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "outgoing credential inside the bounded rotation overlap",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": "rotate"
|
||||
},
|
||||
"presented": {
|
||||
"credential": "outgoing",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "incoming credential immediately after rotation",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": "rotate"
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": true,
|
||||
"reason": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "auth",
|
||||
"fixture": "certificate-profile",
|
||||
"description": "Frozen shape of the client certificate an online device presents and of the RFC 9440 header that conveys it.",
|
||||
"certificate": {
|
||||
"subject": {
|
||||
"rdnCount": 1,
|
||||
"commonName": "{clusterDeviceUid}",
|
||||
"forbiddenAttributes": ["O", "OU", "C", "ST", "L", "emailAddress"]
|
||||
},
|
||||
"subjectAlternativeName": {
|
||||
"entryCount": 1,
|
||||
"type": "uniformResourceIdentifier",
|
||||
"value": "urn:rustfs:connect:device:{clusterDeviceUid}",
|
||||
"forbiddenTypes": ["dNSName", "iPAddress", "rfc822Name", "directoryName"],
|
||||
"wildcardsAccepted": false
|
||||
},
|
||||
"clusterDeviceUid": {
|
||||
"source": "cluster_devices.uid",
|
||||
"format": "lowercase canonical UUIDv7",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"keyAlgorithm": "EC",
|
||||
"keyCurve": "P-256",
|
||||
"signatureAlgorithm": "ES256",
|
||||
"certificateSigningRequest": {
|
||||
"format": "PKCS#10",
|
||||
"signatureAlgorithm": "ES256",
|
||||
"proofOfPossession": "self-signed with the device private key"
|
||||
},
|
||||
"lifetimeSeconds": 86400,
|
||||
"maxRotationOverlapSeconds": 86400,
|
||||
"recommendedRotationLeadSeconds": 28800,
|
||||
"maxPresentableCredentialsPerDevice": 2,
|
||||
"serial": {
|
||||
"encoding": "lowercase-hex",
|
||||
"length": 32,
|
||||
"pattern": "^[0-9a-f]{32}$",
|
||||
"entropyBits": 128
|
||||
},
|
||||
"certificateFingerprint": {
|
||||
"algorithm": "SHA-256",
|
||||
"over": "DER certificate",
|
||||
"encoding": "lowercase-hex",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"publicKeyFingerprint": {
|
||||
"algorithm": "SHA-256",
|
||||
"over": "DER SubjectPublicKeyInfo",
|
||||
"encoding": "lowercase-hex",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"keyId": {
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]{7,127}$"
|
||||
},
|
||||
"carriesOrganizationIdentifier": false,
|
||||
"carriesClusterIdentifier": false,
|
||||
"tenantBinding": {
|
||||
"source": "device_credentials matched by certificate serial and certificate fingerprint",
|
||||
"resolver": "ClusterDeviceIdentityPort::resolveOnlineCertificate"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"name": "Client-Cert",
|
||||
"specification": "RFC 9440",
|
||||
"encoding": "sf-binary",
|
||||
"valueTemplate": ":{base64(DER certificate)}:",
|
||||
"example": ":MIIBkDCCATagAwIBAgIQZXhhbXBsZQ==:",
|
||||
"chainHeader": {
|
||||
"name": "Client-Cert-Chain",
|
||||
"accepted": false,
|
||||
"reason": "Chain validation belongs to the trusted ingress, which verifies against the Connect device CA before forwarding."
|
||||
},
|
||||
"setByTrustedIngressOnly": true,
|
||||
"inboundHeaderStripped": true,
|
||||
"appendAccepted": false,
|
||||
"acceptedOnSurfaces": ["/agent"],
|
||||
"ignoredOnSurfaces": ["/api"],
|
||||
"backendPubliclyReachable": false
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "auth",
|
||||
"fixture": "error-codes",
|
||||
"description": "Frozen ErrorInfo reasons for agent authentication and negotiation. Clients branch on status and reason, never on message.",
|
||||
"domain": "rustfs.connect",
|
||||
"detailType": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"disclosureRules": [
|
||||
"A rejection never reveals whether an unknown certificate belongs to another tenant.",
|
||||
"A rejection never contains certificate bytes, key material, or a fingerprint."
|
||||
],
|
||||
"reasons": [
|
||||
{
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The requested protocol major version is missing, malformed, or not supported."
|
||||
},
|
||||
{
|
||||
"reason": "CLIENT_CERTIFICATE_MISSING",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The request reached an authenticated agent operation without a Client-Cert header from trusted ingress."
|
||||
},
|
||||
{
|
||||
"reason": "CLIENT_CERTIFICATE_MALFORMED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The Client-Cert header is not a valid RFC 9440 byte sequence, or the certificate violates the frozen profile."
|
||||
},
|
||||
{
|
||||
"reason": "CLIENT_CERTIFICATE_UNKNOWN",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "No device credential matches the presented certificate serial and fingerprint together."
|
||||
},
|
||||
{
|
||||
"reason": "CREDENTIAL_NOT_YET_VALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The matched credential's validity window has not opened yet."
|
||||
},
|
||||
{
|
||||
"reason": "CREDENTIAL_EXPIRED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The matched credential's validity window has closed, ending any rotation overlap."
|
||||
},
|
||||
{
|
||||
"reason": "CREDENTIAL_REVOKED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The matched credential is REVOKED. Revocation takes effect immediately."
|
||||
},
|
||||
{
|
||||
"reason": "CREDENTIAL_COMPROMISED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The matched credential is COMPROMISED, for example after a clone was observed."
|
||||
},
|
||||
{
|
||||
"reason": "CLUSTER_DISABLED",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"meaning": "The credential is intact but its cluster is DISABLED, so no agent activity is accepted."
|
||||
},
|
||||
{
|
||||
"reason": "CLUSTER_DELETED",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"meaning": "The credential is intact but its cluster is DELETED."
|
||||
},
|
||||
{
|
||||
"reason": "TENANT_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"meaning": "The authenticated device belongs to a different organization than the resource named by the request."
|
||||
},
|
||||
{
|
||||
"reason": "SESSION_CREDENTIAL_NOT_ACCEPTED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "A browser session cookie was presented to an authenticated agent operation. The agent surface never accepts it."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "auth",
|
||||
"fixture": "reject-vectors",
|
||||
"description": "Presented certificates that must not authenticate. A credential that is known but unusable still resolves, so the rejection can be audited against a device instead of being reported as an unknown certificate.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "revoked credential",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": "revoke"
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CREDENTIAL_REVOKED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compromised credential",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": "markCompromised"
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CREDENTIAL_COMPROMISED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "credential whose validity window has closed",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -86460,
|
||||
"validUntilOffsetSeconds": -60,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CREDENTIAL_EXPIRED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "credential whose validity window has not opened",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": 3600,
|
||||
"validUntilOffsetSeconds": 90000,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CREDENTIAL_NOT_YET_VALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "intact credential on a disabled cluster",
|
||||
"clusterState": "DISABLED",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CLUSTER_DISABLED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "intact credential on a deleted cluster",
|
||||
"clusterState": "DELETED",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CLUSTER_DELETED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "certificate Connect never issued",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "unrelated",
|
||||
"certificateFingerprint": "unrelated"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": false,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CLIENT_CERTIFICATE_UNKNOWN"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "issued serial presented with a substituted certificate",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "matching",
|
||||
"certificateFingerprint": "unrelated"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": false,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CLIENT_CERTIFICATE_UNKNOWN"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "issued certificate presented under a substituted serial",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"presented": {
|
||||
"credential": "current",
|
||||
"serial": "unrelated",
|
||||
"certificateFingerprint": "matching"
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": false,
|
||||
"authenticationEffective": false,
|
||||
"reason": "CLIENT_CERTIFICATE_UNKNOWN"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tenantVector": {
|
||||
"name": "authenticated device reaching a resource owned by another organization",
|
||||
"description": "The certificate is valid and its credential is effective. Only the stored organization decides what the device may reach, and the certificate carries no organization identifier to contradict it.",
|
||||
"clusterState": "ACTIVE",
|
||||
"credential": {
|
||||
"validFromOffsetSeconds": -3600,
|
||||
"validUntilOffsetSeconds": 82800,
|
||||
"transition": null
|
||||
},
|
||||
"expected": {
|
||||
"credentialResolved": true,
|
||||
"authenticationEffective": true,
|
||||
"resolvedIdentityBelongsToForeignOrganization": false,
|
||||
"reason": "TENANT_MISMATCH"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "auth",
|
||||
"fixture": "surface-separation",
|
||||
"description": "The control surface and the agent surface have disjoint credentials. Neither accepts the other's, and neither OpenAPI document declares the other's security scheme.",
|
||||
"httpVectors": [
|
||||
{
|
||||
"name": "agent client certificate presented to the control surface",
|
||||
"surface": "/api",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/session",
|
||||
"headers": {
|
||||
"Client-Cert": ":MIIBkDCCATagAwIBAgIQZXhhbXBsZQ==:"
|
||||
},
|
||||
"browserSession": false
|
||||
},
|
||||
"expected": {
|
||||
"authenticated": false,
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"reason": "UNAUTHENTICATED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browser session presented to the agent surface",
|
||||
"surface": "/agent",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/agent/protocolStatus",
|
||||
"headers": {},
|
||||
"browserSession": true
|
||||
},
|
||||
"expected": {
|
||||
"deviceIdentityEstablished": false,
|
||||
"httpStatus": 200,
|
||||
"body": {
|
||||
"protocolVersion": "v1"
|
||||
},
|
||||
"identicalToUnauthenticatedRequest": true,
|
||||
"note": "getProtocolStatus is the pre-registration operation and is public on purpose. An authenticated browser session neither changes its answer nor grants anything on the agent surface."
|
||||
}
|
||||
}
|
||||
],
|
||||
"documentVectors": [
|
||||
{
|
||||
"document": "openapi/agent.json",
|
||||
"securityScheme": "agentMutualTls",
|
||||
"schemeType": "mutualTLS",
|
||||
"forbiddenSecuritySchemes": ["sessionCookie"],
|
||||
"defaultSecurity": ["agentMutualTls"],
|
||||
"publicOperations": ["getProtocolStatus"]
|
||||
},
|
||||
{
|
||||
"document": "openapi/control.json",
|
||||
"securityScheme": "sessionCookie",
|
||||
"schemeType": "apiKey",
|
||||
"forbiddenSecuritySchemes": ["agentMutualTls"],
|
||||
"defaultSecurity": [],
|
||||
"publicOperations": null
|
||||
}
|
||||
],
|
||||
"routeGuards": {
|
||||
"surfacePrefix": "agent/",
|
||||
"description": "No agent route may be protected by a session authentication guard. A device is identified by its certificate or not at all.",
|
||||
"forbiddenMiddlewarePrefixes": ["auth:", "auth.session"],
|
||||
"forbiddenMiddleware": ["auth"],
|
||||
"forbiddenMiddlewareClasses": [
|
||||
"Illuminate\\Auth\\Middleware\\Authenticate",
|
||||
"Illuminate\\Auth\\Middleware\\AuthenticateSession"
|
||||
],
|
||||
"knownGap": {
|
||||
"middleware": "Laravel\\Sanctum\\Http\\Middleware\\EnsureFrontendRequestsAreStateful",
|
||||
"description": "The agent routes still share the api middleware group with the control surface, so Sanctum's stateful frontend middleware runs on them. It establishes no device identity and no agent route uses an authentication guard, but the agent surface should get its own middleware group when the first authenticated agent operation lands.",
|
||||
"owner": "the issue that adds the first authenticated agent operation"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
4dacc8f8b7fd7f3820dbef4ea4611207dce4fb0bada287c09451499f951f64cd accept-vectors.json
|
||||
afe10983a0de23cf2e1400399bb8a757de90cff2c6120d90cd83b0d28bc2cad2 error-codes.json
|
||||
22133a5cbd5cd36588987d3540c9cadde063faa0ea529dc913f19ed2dc253dea manifest-signing.json
|
||||
04aedbaacdac72fa2a0df22edf6a8c402645a972713778c6031d6c643976a14c reject-vectors.json
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "bundle",
|
||||
"fixture": "accept-vectors",
|
||||
"description": "Support bundle manifests that verify. The second vector is the load-bearing one: its bytes are deliberately non-canonical, and re-serialising them before verification breaks the signature. bytes is standard padded base64 of the exact raw manifest octets.",
|
||||
"authorisedBundle": {
|
||||
"bundleUid": "0198f3a1-8000-7e50-8f61-4a5b6c7d8e94",
|
||||
"organizationName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50",
|
||||
"clusterName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61",
|
||||
"deviceName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61/clusterDevices/0198f3a1-6e00-7c30-ad41-2e3f4a5b6c72",
|
||||
"effectiveDeviceKeyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"note": "What Connect authorised, read from the support_bundles row and the enrolled device key. Every name in a manifest is compared against this and never trusted on its own."
|
||||
},
|
||||
"vectors": [
|
||||
{
|
||||
"name": "manifest signed by the effective device key",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICI0dE1NQndDY1hqRVUxTEM1cDlNeGJaLTM3VGVZRFFSWWpSanNnbTR5dWUwIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "st6Svkk8UgwDMq_8rEomsdIdANyPjqTYFTWPN4FISYor3su27YNRnoWWIyemileZusezfHd8BlxuiSvzJrCcnw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"organizationMatches": true,
|
||||
"clusterMatches": true,
|
||||
"deviceMatches": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest whose bytes no re-serialiser would reproduce",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwgIAoJImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdFwvMSIsCiAgICJlbnRyaWVzIjogWwogICAgICB7ICJzaGEyNTYiOiAiMWQ5NjU3YTY3ZGZjMTJhMGM2Zjk3NDU4NmMwNDFkNmRjZTZmNDgzM2ZiOTBkZmE2MGFhZjBhNDU1ZGRjMTY3OSIsICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIsICJwYXRoIjogImRpYWdub3N0aWNzXC9vZmZsaW5lLXN1bW1hcnkuanNvbiIsICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsICJzaXplQnl0ZXMiOiAyMDQ4IH0sCiAgICAgIHsicGF0aCI6ICJyZWRhY3Rpb25cL3JlcG9ydC5qc29uIiwJInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsICJzaXplQnl0ZXMiOiA1MTIsICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsICJjbGFzc2lmaWNhdGlvbiI6ICJMMCJ9CiAgIF0sCiAgIm5vbmNlIjogIjhEUFJ4TGVVclVzTWdia09MWkJsTE5LMXRmQVFhTHNuVHVWRmxXX0d3ZkEiLCAgIAogICAgICAicHJvZHVjZWRBdCI6ICIyMDI2LTA4LTIwVDA5OjMxOjAwWiIsCiAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAib3JnYW5pemF0aW9uTmFtZSI6ICJvcmdhbml6YXRpb25zXC8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAiY2x1c3Rlck5hbWUiOiAib3JnYW5pemF0aW9uc1wvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwXC9jbHVzdGVyc1wvMDE5OGYzYTEtNWQwMC03YjIwLTljMzEtMWQyZTNmNGE1YjYxIiwKICAgImRldmljZU5hbWUiOiAib3JnYW5pemF0aW9uc1wvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwXC9jbHVzdGVyc1wvMDE5OGYzYTEtNWQwMC03YjIwLTljMzEtMWQyZTNmNGE1YjYxXC9jbHVzdGVyRGV2aWNlc1wvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgInJlZGFjdGlvblZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3QucmVkYWN0aW9uLnYxIiwKCSJydWxlc2V0SGFzaCI6ICJiMzc0MzZkOGU3MjUxNTM5NGExMjJkNjMzODY1YjFkYzAyOGQ0ZWNlMzQ5MzUyYTBhM2EyM2Y1MmNhNDI4NWYzIiwgIAogICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxCn0=",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "byAAzj9jJJSXCv50pU2M5ugSES4kgN_DQzY_CFFDG64rXqn8EBApEGH_a2_TodAyArhpoM2HicOmHi_csL11cQ"
|
||||
}
|
||||
},
|
||||
"nonCanonical": {
|
||||
"keyOrderDiffersFromSchema": true,
|
||||
"mixedIndentation": true,
|
||||
"containsTabs": true,
|
||||
"containsTrailingWhitespace": true,
|
||||
"escapesSolidus": true,
|
||||
"entriesOnOneLine": true,
|
||||
"note": "Every one of these survives a signature over the raw octets and none survives a re-serialisation. reject-vectors.json carries the re-serialised copy of exactly this document under the same signature; it must fail."
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"organizationMatches": true,
|
||||
"clusterMatches": true,
|
||||
"deviceMatches": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest carrying an unknown optional field is accepted and the field is discarded",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJ6eTg4bmI2QXNQbXZYa2YxR3hwTU1VYnhqcEdGQ2llWkFGQ19XMDFJZUVjIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdLAogICAgImNvbGxlY3RvckhpbnQiOiAiaWdub3JlZCIKfQo=",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "YeAkr64tXPUwLUjNNKU2AGsYKzt47uh0l6H_3OwaL5cQ28fGmGoYOMIN8jXLkiEJlbTiAQ0jG7Uda6LK0CD7qw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"organizationMatches": true,
|
||||
"clusterMatches": true,
|
||||
"deviceMatches": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null,
|
||||
"discardedFields": [
|
||||
"collectorHint"
|
||||
],
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "bundle",
|
||||
"fixture": "error-codes",
|
||||
"description": "Frozen ErrorInfo reasons for support bundle manifest validation, and the closed persisted code each one collapses into.",
|
||||
"domain": "rustfs.connect",
|
||||
"detailType": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"disclosureRules": [
|
||||
"A rejection never reveals whether a bundle, key, or device belongs to another tenant.",
|
||||
"A rejection never contains manifest bytes, entry paths, key material, or digests.",
|
||||
"Only supportBundleRejectionCode is persisted. The protocol reason stays in the verifier."
|
||||
],
|
||||
"reasons": [
|
||||
{
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The protocolVersion is missing, malformed, or names an unsupported major version."
|
||||
},
|
||||
{
|
||||
"reason": "UNSUPPORTED_FORMAT",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The formatVersion is not rustfs.connect.support.bundleManifest/1."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The signature is not 64 octets of fixed-width r||s in unpadded base64url, or r or s is out of range."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_NOT_CANONICAL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The signature verifies but its s exceeds half the group order."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "ECDSA verification over the received manifest octets failed. Tampering and re-serialisation both land here."
|
||||
},
|
||||
{
|
||||
"reason": "DEVICE_KEY_UNKNOWN",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "No enrolled device key matches deviceKeyId for this bundle."
|
||||
},
|
||||
{
|
||||
"reason": "DEVICE_KEY_REVOKED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The device key is known but revoked. Revocation is not a validity window and is not retroactively forgiven."
|
||||
},
|
||||
{
|
||||
"reason": "ORGANIZATION_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The manifest names a different organization than the bundle Connect authorised."
|
||||
},
|
||||
{
|
||||
"reason": "CLUSTER_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The manifest names a different cluster than the bundle Connect authorised."
|
||||
},
|
||||
{
|
||||
"reason": "DEVICE_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The manifest names a different device than the bundle Connect authorised."
|
||||
},
|
||||
{
|
||||
"reason": "MANIFEST_NOT_YET_VALID",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "producedAt is further in the future than the skew tolerance."
|
||||
},
|
||||
{
|
||||
"reason": "MANIFEST_EXPIRED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "producedAt is older than the freshness window."
|
||||
},
|
||||
{
|
||||
"reason": "CLASSIFICATION_NOT_PERMITTED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "An entry declares a classification level that is not collected in this release."
|
||||
},
|
||||
{
|
||||
"reason": "ENTRY_TYPE_UNKNOWN",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "An entry declares a type outside the closed set."
|
||||
},
|
||||
{
|
||||
"reason": "REDACTION_VERSION_UNKNOWN",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "redactionVersion is not an identifier Connect implements. The identifier is opaque, so a newer looking one is never treated as a superset of an older one."
|
||||
},
|
||||
{
|
||||
"reason": "REDACTION_RULESET_MISMATCH",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "redactionVersion is known but rulesetHash is not the hash of the rules that identifier names, so the identifier would stand for two different treatments."
|
||||
},
|
||||
{
|
||||
"reason": "ENTRY_DIGEST_MISMATCH",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "HASH_MISMATCH",
|
||||
"meaning": "An archive member does not hash to the digest its manifest entry declares."
|
||||
},
|
||||
{
|
||||
"reason": "ENTRY_SIZE_MISMATCH",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"supportBundleRejectionCode": "SIZE_MISMATCH",
|
||||
"meaning": "An archive member is not the size its manifest entry declares."
|
||||
},
|
||||
{
|
||||
"reason": "BUNDLE_REPLAYED",
|
||||
"httpStatus": 409,
|
||||
"status": "ABORTED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID",
|
||||
"meaning": "The manifest nonce was already accepted for this organization, cluster, and device."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "bundle",
|
||||
"fixture": "manifest-signing",
|
||||
"description": "How a support bundle manifest is signed and what it must contain. The signature input is the single detail R07 and every verifier must get exactly right, so it is stated first and proved by the non-canonical vector in accept-vectors.json.",
|
||||
"signatureInput": {
|
||||
"definition": "rustfs-support-bundle-v1 || 0x00 || the exact raw octets of manifest.json as stored in the archive",
|
||||
"domainSeparationTag": "rustfs-support-bundle-v1",
|
||||
"separatorByte": "0x00",
|
||||
"signedOver": "the received manifest octets, byte for byte",
|
||||
"reserialisationPermitted": false,
|
||||
"canonicalJsonPermitted": false,
|
||||
"prohibited": [
|
||||
"Serialising a parsed manifest back to JSON and signing or verifying that.",
|
||||
"Sorting keys, changing indentation, changing solidus escaping, or trimming whitespace before hashing.",
|
||||
"Hashing a manifest read through a JSON library that does not preserve the original octets.",
|
||||
"Verifying a manifest against a copy re-encoded by an HTTP client, a database column, or a template."
|
||||
],
|
||||
"note": "A verifier must hold the received octets, prepend the tag and the separator, and verify. Only after that may it parse. The producer may format the manifest however it likes: correctness comes from the bytes travelling unchanged, not from agreeing on a canonical form."
|
||||
},
|
||||
"signatureEncoding": {
|
||||
"signatureAlgorithm": "ES256",
|
||||
"curve": "P-256",
|
||||
"hash": "SHA-256",
|
||||
"signatureEncoding": "fixed-width-r-s",
|
||||
"signatureLengthBytes": 64,
|
||||
"signatureTransferEncoding": "base64url-unpadded",
|
||||
"signatureValuePattern": "^[A-Za-z0-9_-]{86}$",
|
||||
"lowSRequired": true,
|
||||
"groupOrder": "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551",
|
||||
"maxS": "7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8",
|
||||
"publicKeyEncoding": "sec1-uncompressed",
|
||||
"publicKeyLengthBytes": 65,
|
||||
"publicKeyTransferEncoding": "base64url-unpadded",
|
||||
"subjectPublicKeyInfoDerPrefix": "3059301306072a8648ce3d020106082a8648ce3d030107034200",
|
||||
"keyIdAlgorithm": "SHA-256",
|
||||
"keyIdOver": "DER SubjectPublicKeyInfo",
|
||||
"keyIdEncoding": "lowercase-hex",
|
||||
"keyIdPattern": "^[0-9a-f]{64}$",
|
||||
"documentTransferEncoding": "base64-padded"
|
||||
},
|
||||
"archiveLayout": {
|
||||
"manifestPath": "manifest.json",
|
||||
"signaturePath": "manifest.sig",
|
||||
"signatureDocument": {
|
||||
"fields": [
|
||||
"algorithm",
|
||||
"keyId",
|
||||
"value",
|
||||
"signedFile",
|
||||
"domainSeparationTag"
|
||||
],
|
||||
"signed": false,
|
||||
"note": "The detached signature document carries no security claim of its own. keyId is a lookup hint; a verifier accepts the key only if it is the effective device key for the bundle, never because the document named it."
|
||||
},
|
||||
"entryPathPattern": "^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$",
|
||||
"entryPathMaxLength": 256,
|
||||
"entryPathRules": [
|
||||
"Relative to the archive root, never absolute.",
|
||||
"No . or .. segment, no empty segment, no backslash, no drive letter, no symlink target.",
|
||||
"Unique across the manifest; a duplicate path is a rejection.",
|
||||
"manifest.json and manifest.sig are not manifest entries and must not appear in entries."
|
||||
],
|
||||
"entriesCoverArchive": "Every archive member other than manifest.json and manifest.sig must appear exactly once in entries, and every entry must exist in the archive."
|
||||
},
|
||||
"declaredVersusActual": {
|
||||
"manifestCovers": "the per-entry path, type, size, digest, and classification of the archive members",
|
||||
"manifestDoesNotCover": "the digest or size of the archive itself, which cannot be inside the archive it describes",
|
||||
"archiveDigestDeclaredBy": "support_bundles.declared_sha256 and support_bundles.declared_size_bytes, committed before the upload starts",
|
||||
"archiveDigestMeasuredInto": "support_bundles.actual_sha256 and support_bundles.actual_size_bytes",
|
||||
"order": [
|
||||
"Measure the quarantined object and record actual_sha256 and actual_size_bytes.",
|
||||
"Reject unless the measured archive equals what the device declared.",
|
||||
"Read manifest.json and manifest.sig without extracting anything else.",
|
||||
"Verify the manifest signature over the raw manifest octets.",
|
||||
"Parse the manifest only after the signature verified.",
|
||||
"Check tenancy, freshness, replay, redaction version, classifications, and entry types.",
|
||||
"Verify every entry digest and size against the archive members.",
|
||||
"Only then promote the bundle to READY."
|
||||
],
|
||||
"note": "READY is already unreachable in PostgreSQL unless actual_sha256 = declared_sha256 and actual_size_bytes = declared_size_bytes, so the archive-level check is enforced by the database and this contract adds the manifest-level checks above it."
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"name": "formatVersion",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Exactly rustfs.connect.support.bundleManifest/1."
|
||||
},
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Exactly v1 in this release; the rule is the one frozen in protocol/agent/v1/authentication.md."
|
||||
},
|
||||
{
|
||||
"name": "bundleUid",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Lowercase canonical UUIDv7, the uid of the support_bundles row."
|
||||
},
|
||||
{
|
||||
"name": "organizationName",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "organizations/{organizationUid}. An untrusted locator; Connect compares it against the bundle it authorised and never derives a tenant from it."
|
||||
},
|
||||
{
|
||||
"name": "clusterName",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "organizations/{organizationUid}/clusters/{clusterUid}."
|
||||
},
|
||||
{
|
||||
"name": "deviceName",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "organizations/{organizationUid}/clusters/{clusterUid}/clusterDevices/{clusterDeviceUid}."
|
||||
},
|
||||
{
|
||||
"name": "deviceKeyId",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Lowercase SHA-256 hex of the DER SubjectPublicKeyInfo of the signing device key."
|
||||
},
|
||||
{
|
||||
"name": "nonce",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "32 random octets as unpadded base64url. Unique per organization, cluster, and device for at least the manifest max age."
|
||||
},
|
||||
{
|
||||
"name": "producedAt",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "RFC 3339 UTC with a Z offset and second precision. Advisory device clock, bounded by the frozen freshness window."
|
||||
},
|
||||
{
|
||||
"name": "redactionVersion",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "The opaque identifier of the deterministic redaction ruleset applied before packaging, frozen by protocol/agent/v1/fixtures/redaction/ruleset.json. Compared for equality only, never ordered."
|
||||
},
|
||||
{
|
||||
"name": "rulesetHash",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Lowercase SHA-256 hex of the canonical form of the ruleset named by redactionVersion. Together the pair proves which rules produced the redacted documents in this archive."
|
||||
},
|
||||
{
|
||||
"name": "classificationRegistryVersion",
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null,
|
||||
"note": "The schemaVersion of protocol/data-collection-fields.json the producer collected against."
|
||||
},
|
||||
{
|
||||
"name": "entries",
|
||||
"requiredness": "required",
|
||||
"type": "array",
|
||||
"default": null,
|
||||
"note": "One object per archive member other than manifest.json and manifest.sig."
|
||||
}
|
||||
],
|
||||
"entryFields": [
|
||||
{
|
||||
"name": "path",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null
|
||||
},
|
||||
{
|
||||
"name": "sizeBytes",
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null
|
||||
},
|
||||
{
|
||||
"name": "sha256",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null
|
||||
},
|
||||
{
|
||||
"name": "classification",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null
|
||||
}
|
||||
],
|
||||
"closedEnumerations": {
|
||||
"entryTypes": [
|
||||
"offline-diagnostic",
|
||||
"redaction-report"
|
||||
],
|
||||
"classifications": [
|
||||
"L0",
|
||||
"L1"
|
||||
],
|
||||
"forbiddenClassifications": [
|
||||
"L2",
|
||||
"L3"
|
||||
],
|
||||
"note": "docs/data-classification.md defers L2 and L3 for the first release, so a manifest that declares one is rejected rather than quietly accepted and filtered. Adding an entry type or admitting a classification level is a protocol change with its own ADR and security review, not an additive field."
|
||||
},
|
||||
"redaction": {
|
||||
"meaning": "redactionVersion identifies the deterministic redaction ruleset the producer applied. It is a claim about what was already removed; it is never a request for Connect to redact. rulesetHash pins the exact rules behind that identifier.",
|
||||
"identifierIsOpaque": true,
|
||||
"comparison": "equality",
|
||||
"orderingPermitted": false,
|
||||
"supportedVersions": [
|
||||
"rustfs.connect.redaction.v1"
|
||||
],
|
||||
"versionFormat": "^rustfs\\.connect\\.redaction\\.v[1-9][0-9]*$",
|
||||
"rulesetHashAlgorithm": "sha256",
|
||||
"rulesetHashPattern": "^[0-9a-f]{64}$",
|
||||
"knownRulesetHashes": {
|
||||
"rustfs.connect.redaction.v1": "b37436d8e72515394a122d633865b1dc028d4ece349352a0a3a23f52ca4285f3"
|
||||
},
|
||||
"unknownVersionPolicy": "reject with REDACTION_VERSION_UNKNOWN",
|
||||
"rulesetHashMismatchPolicy": "reject with REDACTION_RULESET_MISMATCH",
|
||||
"definedBy": "protocol/agent/v1/fixtures/redaction/ruleset.json, implemented by api/app/Modules/Diagnostics/Domain/Redaction and frozen by its own issue",
|
||||
"classificationRegistry": "protocol/data-collection-fields.json, described by docs/data-classification.md",
|
||||
"classificationRegistrySchemaVersion": 1,
|
||||
"note": "This fixture cites the redaction and classification registries; it does not define them. A bundle whose redactionVersion Connect does not implement is rejected, because Connect cannot otherwise know what the producer believed it had removed. A known identifier carrying a foreign rulesetHash is rejected for the same reason: the identifier alone would then be a name for two different treatments."
|
||||
},
|
||||
"freshness": {
|
||||
"maxAgeSeconds": 2592000,
|
||||
"maxFutureSkewSeconds": 300,
|
||||
"evaluatedAgainst": "the Connect receive time",
|
||||
"note": "ADR 0003 already makes device clocks advisory. An air-gapped bundle may be couriered for weeks, so the window is generous in the past and tight in the future."
|
||||
},
|
||||
"supportBundleRejectionCodeMapping": {
|
||||
"note": "Every protocol reason below maps into the closed six code set that support_bundles.rejected_reason_code accepts. The detailed reason stays in the verifier and is never persisted, exactly as SupportBundleRejectionReason documents.",
|
||||
"codes": [
|
||||
"ARCHIVE_INVALID",
|
||||
"HASH_MISMATCH",
|
||||
"MANIFEST_INVALID",
|
||||
"SECRET_DETECTED",
|
||||
"SIZE_LIMIT_EXCEEDED",
|
||||
"SIZE_MISMATCH"
|
||||
],
|
||||
"notCoveredByThisContract": {
|
||||
"ARCHIVE_INVALID": "archive structure, entry paths, entry types, and compression ratio, validated before the manifest is read",
|
||||
"SECRET_DETECTED": "redaction review, owned by the redaction issue",
|
||||
"SIZE_LIMIT_EXCEEDED": "the archive size ceiling, owned by the upload authorization issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "bundle",
|
||||
"fixture": "reject-vectors",
|
||||
"description": "Support bundle manifests that must never be accepted. supportBundleRejectionCode is the code that would be persisted on the support_bundles row; the protocol reason itself never leaves the verifier.",
|
||||
"authorisedBundle": {
|
||||
"bundleUid": "0198f3a1-8000-7e50-8f61-4a5b6c7d8e94",
|
||||
"organizationName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50",
|
||||
"clusterName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61",
|
||||
"deviceName": "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61/clusterDevices/0198f3a1-6e00-7c30-ad41-2e3f4a5b6c72",
|
||||
"effectiveDeviceKeyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"note": "What Connect authorised, read from the support_bundles row and the enrolled device key. Every name in a manifest is compared against this and never trusted on its own."
|
||||
},
|
||||
"vectors": [
|
||||
{
|
||||
"name": "re-serialised copy of the accepted non-canonical manifest under its own signature",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiZm9ybWF0VmVyc2lvbiI6ICJydXN0ZnMuY29ubmVjdC5zdXBwb3J0LmJ1bmRsZU1hbmlmZXN0LzEiLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiLAogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4CiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogInJlZGFjdGlvbi9yZXBvcnQuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogInJlZGFjdGlvbi1yZXBvcnQiLAogICAgICAgICAgICAic2l6ZUJ5dGVzIjogNTEyLAogICAgICAgICAgICAic2hhMjU2IjogImRhZjFkNjE1MmZkMzc3NTJjYjk1NWY0NmYzNmU0NmE1MWU4YzE0NmUxYjM3MTgxOTQ2OTY5YzFlNTUxNzM3MzYiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfQogICAgXSwKICAgICJub25jZSI6ICI4RFBSeExlVXJVc01nYmtPTFpCbExOSzF0ZkFRYUxzblR1VkZsV19Hd2ZBIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzE6MDBaIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJidW5kbGVVaWQiOiAiMDE5OGYzYTEtODAwMC03ZTUwLThmNjEtNGE1YjZjN2Q4ZTk0IiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgImRldmljZU5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAvY2x1c3RlcnMvMDE5OGYzYTEtNWQwMC03YjIwLTljMzEtMWQyZTNmNGE1YjYxL2NsdXN0ZXJEZXZpY2VzLzAxOThmM2ExLTZlMDAtN2MzMC1hZDQxLTJlM2Y0YTViNmM3MiIsCiAgICAicmVkYWN0aW9uVmVyc2lvbiI6ICJydXN0ZnMuY29ubmVjdC5yZWRhY3Rpb24udjEiLAogICAgInJ1bGVzZXRIYXNoIjogImIzNzQzNmQ4ZTcyNTE1Mzk0YTEyMmQ2MzM4NjViMWRjMDI4ZDRlY2UzNDkzNTJhMGEzYTIzZjUyY2E0Mjg1ZjMiLAogICAgImNsYXNzaWZpY2F0aW9uUmVnaXN0cnlWZXJzaW9uIjogMQp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "byAAzj9jJJSXCv50pU2M5ugSES4kgN_DQzY_CFFDG64rXqn8EBApEGH_a2_TodAyArhpoM2HicOmHi_csL11cQ"
|
||||
}
|
||||
},
|
||||
"sameParsedDocumentAs": "manifest whose bytes no re-serialiser would reproduce",
|
||||
"expected": {
|
||||
"signatureVerifies": false,
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_INVALID",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tampered manifest bytes with the original signature",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICI0dE1NQndDY1hqRVUxTEM1cDlNeGJaLTM3VGVZRFFSWWpSanNnbTR5dWUwIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ5LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "st6Svkk8UgwDMq_8rEomsdIdANyPjqTYFTWPN4FISYor3su27YNRnoWWIyemileZusezfHd8BlxuiSvzJrCcnw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": false,
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_INVALID",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest naming another organization",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRiNjAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YjYwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWM3MiIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGI2MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTVjNzIvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICIyNlhfTE1yajlBUU9uX1hobzR3WS1HaTBmRFJPMXdvakxPVkhmRU1CaDJVIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "mLo2vgNTBuAHE185MWezEZX_sgU-j7i7NQNfQlKvKw8r6AXhBjEP3yIk2h2uD-lTzonfcHaVKKHImXIwN1U4jA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"organizationMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "ORGANIZATION_MISMATCH",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest naming another cluster inside the same organization",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWM3MiIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJsUTB6NWszNUoxUjNqdldpMG9RSUlaek1mMnFpckVMOW5iOVI2T2FFS1hzIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "560Jc2BYkDzkQ4A2tfGsXfpx1LazwMazfWmOzMi79VUB3keMD8kl6uS3lD3ej_d6hQAR5Blj96-rVGCxhDM_Pw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"clusterMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "CLUSTER_MISMATCH",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest naming another device inside the same cluster",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2ZDgzIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJBX1Bfc0FPdFZ2eG9HQXJYdVBuNzlpYU0yRXllcmJLSXBPbzl4VGZHWmlzIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "-N1-Am_dNSVRKdb5YSXyKB9MYG6x67ZrlNSw98dpkbZHwUGa0rt0yZne8UScYfrqW6SV9r5F2SNUSxsM-gyxQg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"deviceMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "DEVICE_MISMATCH",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest older than the freshness window",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJzUWljMmNNbkt2Ulc0M3VXWm9BbjF5bzRDbHZzU0RuTF9BSFFsbk0tQjhvIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDYtMDFUMDA6MDA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "CU715flInzASTMilUaRKFDPxEkTm4V7O2dwJQQ4QpLR_25KIX7ztyQ0TyodFjcaqC_6mH8vFt6avK0VUH8qpLQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"withinWindow": false,
|
||||
"accepted": false,
|
||||
"reason": "MANIFEST_EXPIRED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest produced further in the future than the skew tolerance",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJ1MnVRa2RSTmk1d19QcGNSZlkzS2JDVHBpM2JoUHFBYkI3MFBVXzZNaGUwIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjFUMDA6MDY6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "d8ozgCiXo8msztj5u503DqG3SBrB8p4efkek8p4zI0pV2C4I0jpf9h7EZhMBQTogUflLr92QRLqvp8ubnbbEIw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"withinWindow": false,
|
||||
"accepted": false,
|
||||
"reason": "MANIFEST_NOT_YET_VALID",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest signed by a device key Connect never enrolled",
|
||||
"signerKey": "foreignDevice",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICI2M2Q4MTg0ZjJiMmVjNjg5NWJiNzFiOTk5YzkwMTkyZDc3MTg2OTRiYTA0MmJhZDg2MzY0YjI3NjI4ZjljYjUwIiwKICAgICJub25jZSI6ICI0S1JEQzc2SzM4MWY5cnN3OWpZT2tFbFF3cmJSeUFZN0FwWHJRYXgwelpRIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "63d8184f2b2ec6895bb71b999c90192d7718694ba042bad86364b27628f9cb50",
|
||||
"value": "CXTr6odGyXA5Ipa8yXkhqQObrO_-OywgW-Q9Cc-S5NxbxJoTT_k-RfC5SCDRBETvQq7NoiZiyrE58oANmeLBSg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"deviceKeyEffective": false,
|
||||
"accepted": false,
|
||||
"reason": "DEVICE_KEY_UNKNOWN",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest signed by a revoked device key",
|
||||
"signerKey": "revokedDevice",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"deviceKeyRevoked": true,
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIxZjZhMGM2ODQ3MTY4NjJjMjUwN2I0OGU3MmRlMjVmYmNiN2RiNWM3YWE2MjlmZjBjMTdlMjBiOGEzODdkN2FkIiwKICAgICJub25jZSI6ICJNUklHRjRxUFVHenFsYW5nUlJNRDNldmlyTWZ4T0FIUlp0ZHlyVmVVRjhrIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "1f6a0c684716862c2507b48e72de25fbcb7db5c7aa629ff0c17e20b8a387d7ad",
|
||||
"value": "lNDuUkKjGK6KOWUnSLWNkG_5yQFE6b2N-N1KkSIUflc-C0FjjYCFuL1b6sex4k9BLNcHoByGlDUjtkgcY4r6WQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"deviceKeyEffective": false,
|
||||
"accepted": false,
|
||||
"reason": "DEVICE_KEY_REVOKED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest declaring an unknown formatVersion",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8yIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJZVGZNTnJIaFRwa1pPUEM4eHd4Wkt1dzZVdTBvVW9iLVQ4RkpUSXI0Ry1BIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "t87_fDlJnpnIDl9jhfyE65RFI0HNt7FqcXDRSgwPcRooC6VP_nMrGXxfJahRXDch61HxgXMocNT02olH6QizPg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_FORMAT",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest declaring an unsupported protocol major version",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjIiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJVbjl2OWdTLUFQaGllX1RFOWR5NklHRW9XNUtCSzRFR3JSeTFjeFhmUHpNIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "NHvQfH8tyFfWT3qmAYX56LnrVsVe6yfJgfS4OFJHAmgaGtQRtlRab-uqJGU2Pc_PTSK-VKEr8lWQt1Gn-hwHOA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest declaring a deferred classification level",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJsbXpTSzFibXE4RmJFdDcyd3hNQnV3bjJZR2ZxeU1MMlNGNFlIWHZKejRvIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJjb25maWcvcnVzdGZzLmpzb24iLAogICAgICAgICAgICAidHlwZSI6ICJvZmZsaW5lLWRpYWdub3N0aWMiLAogICAgICAgICAgICAic2l6ZUJ5dGVzIjogMTI4LAogICAgICAgICAgICAic2hhMjU2IjogIjBiNTlkNTA4MmE2Njk3ZWQzNjRmNzI1ZWY5NmJjZGVlNDA1ZTkwOGZkNzg1YmUzOTc2ZWUzODJmMTJlZGEzYjEiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDIiCiAgICAgICAgfQogICAgXQp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "Huf_o0OKHatXYegSpJmL7-0cHcltNiWYqlM5ntKgVkdYpCgZ2brr3g9_Pub6G8FOqPXMr1lanYj212INlKgVng"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "CLASSIFICATION_NOT_PERMITTED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest declaring an entry type outside the closed set",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJFZk01WFN3ZmR5cVl6amppOHN0US1XQ2ZIU1ByZTZEZHlEblRtbGIyUkF3IiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJsb2dzL3J1c3Rmcy5sb2ciLAogICAgICAgICAgICAidHlwZSI6ICJyYXctbG9nIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDEyOCwKICAgICAgICAgICAgInNoYTI1NiI6ICJhZDg3ODBkM2UwM2MwYzBmOTAzYTk4ZmU2ODJmZjMzMDc3MDg2MDE2N2Q1YzBlYjI5YjBmNTQwNzVmNGQyZGM2IiwKICAgICAgICAgICAgImNsYXNzaWZpY2F0aW9uIjogIkwxIgogICAgICAgIH0KICAgIF0KfQo=",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "ToR73ejy6joSeLY0IEaiE8YRRU0XTWLwWpHW0He1eGcSgR2p3tMvF5no5PSljdZ0fDCg7dI1H_Fs0IOMd8rRqA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "ENTRY_TYPE_UNKNOWN",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest declaring a redaction ruleset Connect does not implement",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJHdzliNi1XVHRXdDh0Z3o3MDNEOXgybk5WOW0yclNITG5waExJZ1FUQ2pZIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MiIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "uAfgEybRN4aDwgYIKVUpZChTFD8han8azDWaor-Qb6R-9jr9byuERDLB6YglwDgFOqMEHlfITuTo0bRKiBikjg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "REDACTION_VERSION_UNKNOWN",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest pairing a known redaction identifier with a foreign ruleset hash",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhlOTQiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICJQYVhjRE1ScDhDcEphbGE5NGVDdTJxdFRaOUhlV05LeXltakxvUWdHSUpvIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "OpHhhD9keGiGFn6ZsN9IUX4_wueOMI5Xerai0ICZD-8mNjJTy8hXPDWSFwBFztZCshkRhP50p3AvThoWfphXcw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "REDACTION_RULESET_MISMATCH",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "manifest reusing the nonce of an already accepted bundle",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-21T00:00:00Z",
|
||||
"nonceAlreadySeen": true,
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Quc3VwcG9ydC5idW5kbGVNYW5pZmVzdC8xIiwKICAgICJwcm90b2NvbFZlcnNpb24iOiAidjEiLAogICAgImJ1bmRsZVVpZCI6ICIwMTk4ZjNhMS04MDAwLTdlNTAtOGY2MS00YTViNmM3ZDhmMDUiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiZGV2aWNlTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEvY2x1c3RlckRldmljZXMvMDE5OGYzYTEtNmUwMC03YzMwLWFkNDEtMmUzZjRhNWI2YzcyIiwKICAgICJkZXZpY2VLZXlJZCI6ICIzOWNhMjRjOGIwMmE1NTlmZDliZWIyYjFmNWQxOGNlZDIwYzRiYjI0NjU3N2I5MjkxNGFlNjgxNGMzZjcwYWNmIiwKICAgICJub25jZSI6ICI0dE1NQndDY1hqRVUxTEM1cDlNeGJaLTM3VGVZRFFSWWpSanNnbTR5dWUwIiwKICAgICJwcm9kdWNlZEF0IjogIjIwMjYtMDgtMjBUMDk6MzA6MDBaIiwKICAgICJyZWRhY3Rpb25WZXJzaW9uIjogInJ1c3Rmcy5jb25uZWN0LnJlZGFjdGlvbi52MSIsCiAgICAicnVsZXNldEhhc2giOiAiYjM3NDM2ZDhlNzI1MTUzOTRhMTIyZDYzMzg2NWIxZGMwMjhkNGVjZTM0OTM1MmEwYTNhMjNmNTJjYTQyODVmMyIsCiAgICAiY2xhc3NpZmljYXRpb25SZWdpc3RyeVZlcnNpb24iOiAxLAogICAgImVudHJpZXMiOiBbCiAgICAgICAgewogICAgICAgICAgICAicGF0aCI6ICJkaWFnbm9zdGljcy9vZmZsaW5lLXN1bW1hcnkuanNvbiIsCiAgICAgICAgICAgICJ0eXBlIjogIm9mZmxpbmUtZGlhZ25vc3RpYyIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiAyMDQ4LAogICAgICAgICAgICAic2hhMjU2IjogIjFkOTY1N2E2N2RmYzEyYTBjNmY5NzQ1ODZjMDQxZDZkY2U2ZjQ4MzNmYjkwZGZhNjBhYWYwYTQ1NWRkYzE2NzkiLAogICAgICAgICAgICAiY2xhc3NpZmljYXRpb24iOiAiTDAiCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAgICJwYXRoIjogImRpYWdub3N0aWNzL2hvc3Qtc3VtbWFyeS5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAib2ZmbGluZS1kaWFnbm9zdGljIiwKICAgICAgICAgICAgInNpemVCeXRlcyI6IDQwOTYsCiAgICAgICAgICAgICJzaGEyNTYiOiAiNDViYjZhMjgxYzk4MzBkOGUxNmQ4NTg3MDA2ODQwMTA0OGY2NDQ0YmFkY2UyMGFhOThlYTNmMGNjMGVkYjIwNCIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMSIKICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICAgInBhdGgiOiAicmVkYWN0aW9uL3JlcG9ydC5qc29uIiwKICAgICAgICAgICAgInR5cGUiOiAicmVkYWN0aW9uLXJlcG9ydCIsCiAgICAgICAgICAgICJzaXplQnl0ZXMiOiA1MTIsCiAgICAgICAgICAgICJzaGEyNTYiOiAiZGFmMWQ2MTUyZmQzNzc1MmNiOTU1ZjQ2ZjM2ZTQ2YTUxZThjMTQ2ZTFiMzcxODE5NDY5NjljMWU1NTE3MzczNiIsCiAgICAgICAgICAgICJjbGFzc2lmaWNhdGlvbiI6ICJMMCIKICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "S24sWFQ8_T3y4e0F25YVoA5DFjgVLsQJZN0IsYu0ZvIUsTBMkpcV2Z3RrAvFqIYtenu1dUIyF0bO5wDS97zOTw"
|
||||
}
|
||||
},
|
||||
"sameNonceAs": "manifest signed by the effective device key",
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "BUNDLE_REPLAYED",
|
||||
"supportBundleRejectionCode": "MANIFEST_INVALID"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"maxFilesPerSet": 12,
|
||||
"manifestFile": "MANIFEST.sha256",
|
||||
"consumerCopy": {
|
||||
"repository": "rustfs/rustfs",
|
||||
"path": "protocol/agent/v1/fixtures",
|
||||
"requirement": "Byte-identical copy of every populated set, compared by make protocol-compat."
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"name": "auth",
|
||||
"status": "populated",
|
||||
"purpose": "Client certificate profile, RFC 9440 header profile, authentication accept and reject vectors, surface separation, and the frozen error reason registry."
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"status": "populated",
|
||||
"purpose": "Protocol version negotiation decisions, the frozen v1 negotiation field registry, and additive compatibility in both skew directions."
|
||||
},
|
||||
{
|
||||
"name": "registration",
|
||||
"status": "populated",
|
||||
"purpose": "Registration token exchange, proof of possession, replay rejection, and certificate issuance."
|
||||
},
|
||||
{
|
||||
"name": "heartbeat",
|
||||
"status": "reserved",
|
||||
"purpose": "Heartbeat payloads, Connect receive time, and freshness window behavior."
|
||||
},
|
||||
{
|
||||
"name": "inventory",
|
||||
"status": "populated",
|
||||
"purpose": "Inventory snapshot payloads and their allow-listed collection fields."
|
||||
},
|
||||
{
|
||||
"name": "offline-enrollment",
|
||||
"status": "populated",
|
||||
"purpose": "Air-gapped device enrolment and signed artifact exchange without a certificate."
|
||||
},
|
||||
{
|
||||
"name": "bundle",
|
||||
"status": "populated",
|
||||
"purpose": "Support bundle manifests, upload authorization, and archive validation."
|
||||
},
|
||||
{
|
||||
"name": "redaction",
|
||||
"status": "populated",
|
||||
"purpose": "Deterministic redaction of telemetry and support bundle content."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
d1a73b0a348845bf3ed9fb68301babc5abbc6e72243a610db1cd4794b1328070 canonical-hash.json
|
||||
f1107d3e6accbaee468f1a1fdb79d7103fb2aadafd85c39020fbbca5173b03b4 field-registry.json
|
||||
b3b2e7f761198d4823c94440637a48153437183f4cacec5118570e1920f73b29 old-agent-vectors.json
|
||||
bec2f30dea2fd4839e501acd94c9d935817f4f1b67cd14d33cad59c0351a23ea reject-vectors.json
|
||||
5493ba0d1477ad3762ed87c410d5830b47fe7aedac511f97b96385a51e258c32 secret-like-vectors.json
|
||||
6e2df36bf266fcca4b2c9a856d0a9ca8ab7e2cfaf7534b9af176c6597cb38137 unknown-field-vectors.json
|
||||
46e5d3398719a31dcc912379e66b4ab06a433d125366cce5239c49956fc4ab1b valid-vectors.json
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "canonical-hash",
|
||||
"description": "The canonical content hash of an L0 inventory snapshot, defined precisely enough that an independent implementation reproduces it byte for byte. The prose form is docs/data-collection/inventory-l0.md; this file is the machine-checkable copy.",
|
||||
"algorithm": "SHA-256",
|
||||
"output": {
|
||||
"encoding": "lowercase hexadecimal",
|
||||
"length": 64,
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"hashedBytes": "domainPrefix followed immediately by canonicalJson encoded as UTF-8, with nothing between them and nothing after",
|
||||
"domainPrefix": {
|
||||
"ascii": "rustfs-connect/agent/v1/inventory-snapshot\n",
|
||||
"hex": "7275737466732d636f6e6e6563742f6167656e742f76312f696e76656e746f72792d736e617073686f740a",
|
||||
"byteLength": 43,
|
||||
"reason": "Domain separation. An inventory hash can never equal a heartbeat, bundle, or job hash computed over the same member values."
|
||||
},
|
||||
"input": {
|
||||
"startsFrom": "the normalized document, after the version gate, after unknown members and unknown coarse flag tokens have been discarded, and after schema and cross-field validation succeeded",
|
||||
"excludedMembers": ["protocolVersion"],
|
||||
"excludedMembersReason": "protocolVersion is negotiation input, not content. Excluding it means the same cluster state hashes identically across protocol versions, and it is why an unknown member can never change the hash.",
|
||||
"materializedDefaults": {
|
||||
"osVersion": null,
|
||||
"coarseFlags": []
|
||||
},
|
||||
"materializedDefaultsReason": "Absent optional members take their documented default and the default is serialized, so the canonical object always carries exactly the same seven member names. An old agent that omits an optional member and a new agent that sends its default explicitly produce the same hash."
|
||||
},
|
||||
"serialization": {
|
||||
"form": "one line of UTF-8 with no insignificant whitespace: no space after a colon or comma, no newline, no trailing newline",
|
||||
"objects": "{ then member,member,... then }, where a member is \"name\":value; an empty object is {}",
|
||||
"arrays": "[ then value,value,... then ]; an empty array is []",
|
||||
"memberOrder": "ascending by the UTF-8 bytes of the member name, compared as unsigned bytes; no locale, no case folding, no UTF-16 code unit order",
|
||||
"arrayOrder": "the normalized order, which for coarseFlags is de-duplicated and sorted ascending by the same unsigned-byte comparison",
|
||||
"null": "the four bytes null",
|
||||
"integers": "base ten, no plus sign, no leading zero except the single digit 0, no decimal point, no exponent; every v1 value is in 0..9007199254740991 so no implementation needs arbitrary precision",
|
||||
"strings": "opened and closed by a double quote with the value's bytes between them",
|
||||
"stringEscaping": "none is ever emitted. Every string value the canonical input can hold matches ^[a-z0-9.]{1,32}$ and every member name matches ^[a-zA-Z]{1,32}$, so no character requiring a JSON escape can reach the hash. A value outside those classes is a schema violation and is rejected before hashing.",
|
||||
"canonicalValueCharacterClass": "^[a-z0-9.]{1,32}$",
|
||||
"canonicalMemberNameCharacterClass": "^[a-zA-Z]{1,32}$",
|
||||
"absentTypes": "the canonical input contains no boolean, no floating point number, and no nested array of objects. Adding a member of one of those types requires extending these rules in a reviewed protocol change."
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"name": "fully populated snapshot",
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"canonicalJsonByteLength": 225,
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f"
|
||||
},
|
||||
{
|
||||
"name": "every limit at its maximum",
|
||||
"canonicalJson": "{\"capacityTotalBytes\":9007199254740991,\"capacityUsedBytes\":9007199254740991,\"coarseFlags\":[\"capacity.critical\",\"capacity.warning\",\"clock.skew\",\"cluster.degraded\",\"cluster.healing\",\"cluster.readonly\",\"drive.offline\",\"node.offline\"],\"driveCount\":1048576,\"nodeCount\":4096,\"osVersion\":{\"family\":\"other\",\"major\":9999,\"minor\":9999},\"rustfsVersion\":\"9999.9999.9999\"}",
|
||||
"canonicalJsonByteLength": 359,
|
||||
"contentHash": "b69d51f898a53562a7057faa5bda1e21699ee6615517cd8b1455096f9171bce4"
|
||||
},
|
||||
{
|
||||
"name": "every limit at its minimum",
|
||||
"canonicalJson": "{\"capacityTotalBytes\":0,\"capacityUsedBytes\":0,\"coarseFlags\":[],\"driveCount\":0,\"nodeCount\":1,\"osVersion\":null,\"rustfsVersion\":\"0.0.0\"}",
|
||||
"canonicalJsonByteLength": 133,
|
||||
"contentHash": "08ebc03ee906c05d686ef32c0998154213f2811a2d07408a9ef7d0b205cc701b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "field-registry",
|
||||
"description": "The frozen v1 inventory snapshot envelope. Every collected member cites its data-collection registry id from protocol/data-collection-fields.json. Any member not listed here is unknown: it is accepted, discarded before validation, never persisted, never echoed back, and its raw text is never logged.",
|
||||
"envelope": "InventorySnapshot",
|
||||
"schema": "protocol/agent/v1/inventory-snapshot.schema.json",
|
||||
"classificationRegistry": "protocol/data-collection-fields.json",
|
||||
"level": "L0",
|
||||
"source": "inventory",
|
||||
"retentionDays": 90,
|
||||
"cadence": "per-inventory",
|
||||
"supportedMajorVersions": [1],
|
||||
"protocolVersionPattern": "^v[1-9][0-9]{0,3}$",
|
||||
"unknownFieldPolicy": "accept-and-discard",
|
||||
"unknownCoarseFlagPolicy": "discard",
|
||||
"unknownMajorVersionPolicy": "reject",
|
||||
"fields": [
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"registryId": null,
|
||||
"collected": false,
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "string",
|
||||
"pattern": "^v[1-9][0-9]{0,3}$"
|
||||
},
|
||||
"includedInContentHash": false,
|
||||
"note": "Negotiation input shared with the frozen v1 negotiation envelope. It gates processing, is never persisted as an inventory attribute, and therefore has no data-collection registry id."
|
||||
},
|
||||
{
|
||||
"name": "rustfsVersion",
|
||||
"registryId": "inventory.rustfsVersion",
|
||||
"collected": true,
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]{0,3})\\.(0|[1-9][0-9]{0,3})\\.(0|[1-9][0-9]{0,3})$"
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "Coarse release only. Pre-release and build metadata are rejected because a build identifier fingerprints a private build."
|
||||
},
|
||||
{
|
||||
"name": "osVersion",
|
||||
"registryId": "inventory.osVersion",
|
||||
"collected": true,
|
||||
"requiredness": "optional",
|
||||
"type": ["object", "null"],
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"required": ["family", "major", "minor"]
|
||||
},
|
||||
"memberLimits": {
|
||||
"family": {
|
||||
"type": "string",
|
||||
"enum": ["linux", "darwin", "windows", "freebsd", "other"]
|
||||
},
|
||||
"major": { "type": "integer", "minimum": 0, "maximum": 9999 },
|
||||
"minor": { "type": "integer", "minimum": 0, "maximum": 9999 }
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "The family is a closed vocabulary, never free text. An unlisted operating system reports 'other'. Unknown members of this object are discarded exactly like unknown top-level members."
|
||||
},
|
||||
{
|
||||
"name": "nodeCount",
|
||||
"registryId": "inventory.nodeCount",
|
||||
"collected": true,
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 4096
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "A cluster reporting inventory has at least one node."
|
||||
},
|
||||
{
|
||||
"name": "driveCount",
|
||||
"registryId": "inventory.driveCount",
|
||||
"collected": true,
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1048576
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "Zero is legal while a cluster is being provisioned."
|
||||
},
|
||||
{
|
||||
"name": "capacityTotalBytes",
|
||||
"registryId": "inventory.capacityTotalBytes",
|
||||
"collected": true,
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "2^53-1 is the largest integer Rust u64, PHP int, JavaScript number, and JSON all represent exactly. Above the cap is a validation error, never a silent truncation."
|
||||
},
|
||||
{
|
||||
"name": "capacityUsedBytes",
|
||||
"registryId": "inventory.capacityUsedBytes",
|
||||
"collected": true,
|
||||
"requiredness": "required",
|
||||
"type": "integer",
|
||||
"default": null,
|
||||
"limits": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "Must not exceed capacityTotalBytes. JSON Schema cannot compare two members, so the validator enforces that invariant separately."
|
||||
},
|
||||
{
|
||||
"name": "coarseFlags",
|
||||
"registryId": "inventory.coarseFlags",
|
||||
"collected": true,
|
||||
"requiredness": "optional",
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"limits": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"itemLimits": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"capacity.critical",
|
||||
"capacity.warning",
|
||||
"clock.skew",
|
||||
"cluster.degraded",
|
||||
"cluster.healing",
|
||||
"cluster.readonly",
|
||||
"drive.offline",
|
||||
"node.offline"
|
||||
]
|
||||
},
|
||||
"includedInContentHash": true,
|
||||
"note": "Allow-listed conditions only. Normalization discards unknown tokens, de-duplicates, and sorts ascending by UTF-8 bytes, so agent-side ordering never changes the content hash."
|
||||
}
|
||||
],
|
||||
"errorReasons": {
|
||||
"description": "Inventory-scoped ErrorInfo reasons. The negotiation reason is not redeclared here: an unsupported major version fails with UNSUPPORTED_PROTOCOL from protocol/agent/v1/fixtures/auth/error-codes.json, before any inventory member is read. INVENTORY_FIELD_INVALID is new in this issue and must not collide with a reason already frozen for authentication.",
|
||||
"versionFailure": "UNSUPPORTED_PROTOCOL",
|
||||
"payloadFailure": {
|
||||
"reason": "INVENTORY_FIELD_INVALID",
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"httpStatus": 400,
|
||||
"domain": "rustfs.connect",
|
||||
"meaning": "The normalized inventory snapshot violates the frozen schema or a cross-field invariant."
|
||||
},
|
||||
"disclosureRules": [
|
||||
"A rejection names the frozen member path that failed and never the value that failed.",
|
||||
"A rejection never names, quotes, or counts an unknown member, so discarded text cannot reach an error body or a log line.",
|
||||
"A raw input bound rejection names the bound that was exceeded and never the body."
|
||||
]
|
||||
},
|
||||
"rawInputBounds": {
|
||||
"description": "Denial-of-service bounds applied to the raw body after the version gate and before normalization. They are deliberately far above what a v1 snapshot needs so that additive growth never trips them. A violation is rejected without naming or echoing the offending value.",
|
||||
"maxBodyBytes": 8192,
|
||||
"maxDepth": 8,
|
||||
"maxTopLevelMembers": 64,
|
||||
"maxRawCoarseFlagItems": 64,
|
||||
"reason": "INVENTORY_FIELD_INVALID",
|
||||
"httpStatus": 400
|
||||
},
|
||||
"crossFieldInvariants": [
|
||||
{
|
||||
"name": "usedNeverExceedsTotal",
|
||||
"expression": "capacityUsedBytes <= capacityTotalBytes",
|
||||
"reason": "INVENTORY_FIELD_INVALID"
|
||||
}
|
||||
],
|
||||
"forbiddenFieldClasses": {
|
||||
"description": "L0 inventory cannot express any of these. The frozen schema sets additionalProperties false at every level and every string member is an enum or a numeric-component pattern, so none of these sample values can be carried by any member, known or unknown, that survives normalization.",
|
||||
"classes": [
|
||||
{ "class": "bucket", "samples": ["customer-backups", "prod-media-eu"] },
|
||||
{ "class": "object", "samples": ["invoices/2026/08/inv-1.pdf", "db-dump.sql.gz"] },
|
||||
{ "class": "path", "samples": ["/var/lib/rustfs/data/disk1", "C:\\rustfs\\data"] },
|
||||
{ "class": "endpoint", "samples": ["https://rustfs.internal:9000", "10.4.2.17:9000", "node-3.storage.example.com"] },
|
||||
{ "class": "configuration", "samples": ["RUSTFS_ERASURE_SET_SIZE=12", "{\"tls\":{\"minVersion\":\"1.3\"}}"] },
|
||||
{ "class": "credential", "samples": ["AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2ln", "-----BEGIN PRIVATE KEY-----"] },
|
||||
{ "class": "identifier", "samples": ["node-3.storage.example.com", "aa:bb:cc:dd:ee:ff", "acme-corp"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "old-agent-vectors",
|
||||
"description": "Release skew in both directions, plus the version gate. The version rule is not restated here: it is the one frozen in protocol/agent/v1/fixtures/version/field-registry.json, and the inventory field registry repeats its pattern and supported majors so the two can be compared. An unsupported major fails closed before any inventory member is read, so nothing is normalized, validated, hashed, stored, or echoed.",
|
||||
"versionRule": {
|
||||
"inheritedFrom": "protocol/agent/v1/fixtures/version/field-registry.json",
|
||||
"protocolVersionPattern": "^v[1-9][0-9]{0,3}$",
|
||||
"supportedMajorVersions": [1],
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
},
|
||||
"baseContentHash": "1d6f2b1d767df63fbf72d6f7abcc98d5c47ed6783739e449f898ea5898e5e356",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "old agent omits every optional member",
|
||||
"category": "old-agent",
|
||||
"direction": "old-agent-to-new-connect",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": { "osVersion": null, "coarseFlags": [] },
|
||||
"canonicalJson": "{\"capacityTotalBytes\":549755813888,\"capacityUsedBytes\":137438953472,\"coarseFlags\":[],\"driveCount\":16,\"nodeCount\":4,\"osVersion\":null,\"rustfsVersion\":\"1.0.0\"}",
|
||||
"contentHash": "1d6f2b1d767df63fbf72d6f7abcc98d5c47ed6783739e449f898ea5898e5e356",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "newer agent sends the documented defaults explicitly",
|
||||
"category": "old-agent",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"sameContentHashAs": "old agent omits every optional member",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.0.0",
|
||||
"osVersion": null,
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472,
|
||||
"coarseFlags": []
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":549755813888,\"capacityUsedBytes\":137438953472,\"coarseFlags\":[],\"driveCount\":16,\"nodeCount\":4,\"osVersion\":null,\"rustfsVersion\":\"1.0.0\"}",
|
||||
"contentHash": "1d6f2b1d767df63fbf72d6f7abcc98d5c47ed6783739e449f898ea5898e5e356",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "newer agent adds a member this Connect does not know",
|
||||
"category": "old-agent",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"sameContentHashAs": "old agent omits every optional member",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472,
|
||||
"poolCount": 2
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes"
|
||||
],
|
||||
"discarded": ["poolCount"],
|
||||
"defaultsApplied": { "osVersion": null, "coarseFlags": [] },
|
||||
"canonicalJson": "{\"capacityTotalBytes\":549755813888,\"capacityUsedBytes\":137438953472,\"coarseFlags\":[],\"driveCount\":16,\"nodeCount\":4,\"osVersion\":null,\"rustfsVersion\":\"1.0.0\"}",
|
||||
"contentHash": "1d6f2b1d767df63fbf72d6f7abcc98d5c47ed6783739e449f898ea5898e5e356",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "next major version from a future agent",
|
||||
"category": "old-agent",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"input": {
|
||||
"protocolVersion": "v2",
|
||||
"rustfsVersion": "2.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": null,
|
||||
"contentHash": null,
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "far future major version",
|
||||
"category": "old-agent",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"input": {
|
||||
"protocolVersion": "v9999",
|
||||
"rustfsVersion": "9999.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": null,
|
||||
"contentHash": null,
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a secret-like unknown member does not rescue an unsupported major version",
|
||||
"category": "old-agent",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"input": {
|
||||
"protocolVersion": "v2",
|
||||
"compatibilityShim": "v1",
|
||||
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"rustfsVersion": "2.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": null,
|
||||
"contentHash": null,
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "an agent that omits the protocol version",
|
||||
"category": "old-agent",
|
||||
"direction": "old-agent-to-new-connect",
|
||||
"input": {
|
||||
"rustfsVersion": "1.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": null,
|
||||
"contentHash": null,
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a dotted protocol version is not a major version",
|
||||
"category": "old-agent",
|
||||
"direction": "old-agent-to-new-connect",
|
||||
"input": {
|
||||
"protocolVersion": "v1.2",
|
||||
"rustfsVersion": "1.0.0",
|
||||
"nodeCount": 4,
|
||||
"driveCount": 16,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 137438953472
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": null,
|
||||
"contentHash": null,
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "reject-vectors",
|
||||
"description": "Snapshots the frozen schema and its cross-field invariant refuse. Every vector is rejected after normalization, so nothing here is stored, echoed, or hashed. violation names the member and the schema keyword that must fire; a rejection that fires on a different member is as much a regression as one that does not fire at all. The negative category covers malformed, missing, negative, and mistyped values; the overflow category covers values above a frozen maximum.",
|
||||
"reason": "INVENTORY_FIELD_INVALID",
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"httpStatus": 400,
|
||||
"vectors": [
|
||||
{
|
||||
"name": "a node count of zero",
|
||||
"category": "negative",
|
||||
"violation": { "member": "nodeCount", "rule": "minimum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 0,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a negative node count",
|
||||
"category": "negative",
|
||||
"violation": { "member": "nodeCount", "rule": "minimum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": -1,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a negative drive count",
|
||||
"category": "negative",
|
||||
"violation": { "member": "driveCount", "rule": "minimum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": -1,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a negative used capacity",
|
||||
"category": "negative",
|
||||
"violation": { "member": "capacityUsedBytes", "rule": "minimum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": -1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a negative capacity on both members",
|
||||
"category": "negative",
|
||||
"violation": { "member": "capacityTotalBytes", "rule": "minimum" },
|
||||
"note": "A negative total with a non-negative used member would break the cross-field invariant as well, which would make it ambiguous which rule fired. Both members are negative so the minimum keyword is the only thing this vector can trip.",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": -1,
|
||||
"capacityUsedBytes": -1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a negative operating system major version",
|
||||
"category": "negative",
|
||||
"violation": { "member": "osVersion.major", "rule": "minimum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": -1, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a missing RustFS version",
|
||||
"category": "negative",
|
||||
"violation": { "member": "rustfsVersion", "rule": "required" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a missing used capacity",
|
||||
"category": "negative",
|
||||
"violation": { "member": "capacityUsedBytes", "rule": "required" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a two component RustFS version",
|
||||
"category": "negative",
|
||||
"violation": { "member": "rustfsVersion", "rule": "pattern" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a RustFS version carrying pre-release and build metadata",
|
||||
"category": "negative",
|
||||
"violation": { "member": "rustfsVersion", "rule": "pattern" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2-rc.1+9f2c1a",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a RustFS version component with a leading zero",
|
||||
"category": "negative",
|
||||
"violation": { "member": "rustfsVersion", "rule": "pattern" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "01.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "an operating system family outside the closed vocabulary",
|
||||
"category": "negative",
|
||||
"violation": { "member": "osVersion.family", "rule": "enum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "ubuntu", "major": 24, "minor": 4 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "an operating system version missing its minor component",
|
||||
"category": "negative",
|
||||
"violation": { "member": "osVersion.minor", "rule": "required" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "used capacity above total capacity",
|
||||
"category": "negative",
|
||||
"violation": { "member": "capacityUsedBytes", "rule": "usedNeverExceedsTotal" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 1099511627777
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a node count sent as a string",
|
||||
"category": "negative",
|
||||
"violation": { "member": "nodeCount", "rule": "type" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": "8",
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a fractional capacity",
|
||||
"category": "negative",
|
||||
"violation": { "member": "capacityTotalBytes", "rule": "type" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776.5,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "coarse flags sent as a string",
|
||||
"category": "negative",
|
||||
"violation": { "member": "coarseFlags", "rule": "type" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": "cluster.degraded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "an operating system version sent as a string",
|
||||
"category": "negative",
|
||||
"violation": { "member": "osVersion", "rule": "type" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": "linux 6.8",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a node count above the frozen maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "nodeCount", "rule": "maximum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 4097,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a drive count above the frozen maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "driveCount", "rule": "maximum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 1048577,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a total capacity one byte above the exactly representable maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "capacityTotalBytes", "rule": "maximum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 9007199254740992,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "both capacities one byte above the exactly representable maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "capacityUsedBytes", "rule": "maximum" },
|
||||
"note": "The used member carries the same cap as the total member. Used capacity can only exceed the cap when total capacity does too, so this vector raises both and asserts the used member is bounded in its own right.",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 9007199254740992,
|
||||
"capacityUsedBytes": 9007199254740992
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a RustFS version component above the frozen maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "rustfsVersion", "rule": "pattern" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "10000.0.0",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "an operating system major version above the frozen maximum",
|
||||
"category": "overflow",
|
||||
"violation": { "member": "osVersion.major", "rule": "maximum" },
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 10000, "minor": 0 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "secret-like-vectors",
|
||||
"description": "An agent, a proxy, or an attacker attaches credential-shaped and customer-identifying members to an otherwise valid snapshot. Every one is an unknown optional member, so the frozen rule applies unchanged: accepted, discarded, never persisted, never echoed back, and never logged in raw form. Rejecting them instead would break additive compatibility and would put the secret text into an error message, so discarding is the safer and the frozen behaviour. Each vector carries the same content hash as the valid fixture's fully populated snapshot, which proves nothing here reaches storage. The literal values below are non-functional examples, not real credentials.",
|
||||
"baseVector": "fully populated snapshot",
|
||||
"baseContentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"errorMessagesNameUnknownMembers": false,
|
||||
"vectors": [
|
||||
{
|
||||
"name": "S3 access key and secret key attached to the snapshot",
|
||||
"category": "secret-like",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"secretLikeMembers": ["accessKeyId", "secretAccessKey", "sessionToken"],
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"sessionToken": "FwoGZXIvYXdzEExampleSessionTokenValue"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["accessKeyId", "secretAccessKey", "sessionToken"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bearer token and private key material attached to the snapshot",
|
||||
"category": "secret-like",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"secretLikeMembers": ["authorization", "devicePrivateKeyPem", "kmsMasterKey"],
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.e30.c2lnbmF0dXJl",
|
||||
"devicePrivateKeyPem": "-----BEGIN PRIVATE KEY-----\nMEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBleGFtcGxl\n-----END PRIVATE KEY-----",
|
||||
"kmsMasterKey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["authorization", "devicePrivateKeyPem", "kmsMasterKey"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bucket object path and endpoint members attached to the snapshot",
|
||||
"category": "secret-like",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"secretLikeMembers": ["buckets", "largestObjectKey", "dataPaths", "endpoint"],
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"buckets": ["customer-backups", "prod-media-eu"],
|
||||
"largestObjectKey": "invoices/2026/08/inv-1.pdf",
|
||||
"dataPaths": ["/var/lib/rustfs/data/disk1", "C:\\rustfs\\data"],
|
||||
"endpoint": "https://rustfs.internal:9000"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["buckets", "dataPaths", "endpoint", "largestObjectKey"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "redacted configuration and customer identifiers attached to the snapshot",
|
||||
"category": "secret-like",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"secretLikeMembers": ["configuration", "customerName", "nodeHostnames"],
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"configuration": { "tls": { "minVersion": "1.3" }, "erasure": "RUSTFS_ERASURE_SET_SIZE=12" },
|
||||
"customerName": "acme-corp",
|
||||
"nodeHostnames": ["node-3.storage.example.com", "10.4.2.17"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["configuration", "customerName", "nodeHostnames"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "secret material hidden inside osVersion and inside a coarse flag token",
|
||||
"category": "secret-like",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"secretLikeMembers": ["osVersion.licenseKey", "coarseFlags[akiaiosfodnn7example]"],
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": {
|
||||
"family": "linux",
|
||||
"major": 6,
|
||||
"minor": 8,
|
||||
"licenseKey": "RUSTFS-PROD-4821-9930-ACME"
|
||||
},
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline", "akiaiosfodnn7example"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["coarseFlags[akiaiosfodnn7example]", "osVersion.licenseKey"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "unknown-field-vectors",
|
||||
"description": "Unknown optional members and unknown coarse flag tokens. Every one is accepted, discarded before validation, never persisted, never echoed back, and never logged in raw form. Every vector here carries the same content hash as the valid fixture's fully populated snapshot, which is the executable statement that an unknown member cannot influence stored inventory. The discarded list is bookkeeping: its membership is the contract, not its order.",
|
||||
"baseVector": "fully populated snapshot",
|
||||
"baseContentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "one unknown optional member from a newer agent",
|
||||
"category": "unknown-field",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"telemetryProfile": "extended"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["telemetryProfile"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown optional members of every JSON type at once",
|
||||
"category": "unknown-field",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"erasureSetSize": 12,
|
||||
"experimentalFlags": { "fastInventory": true },
|
||||
"regionHints": ["eu-west", "us-east"],
|
||||
"supersededBy": null,
|
||||
"telemetryProfile": "extended"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [
|
||||
"erasureSetSize",
|
||||
"experimentalFlags",
|
||||
"regionHints",
|
||||
"supersededBy",
|
||||
"telemetryProfile"
|
||||
],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown member nested inside osVersion",
|
||||
"category": "unknown-field",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": {
|
||||
"family": "linux",
|
||||
"major": 6,
|
||||
"minor": 8,
|
||||
"distribution": "debian",
|
||||
"kernelRelease": "6.8.0-41-generic"
|
||||
},
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["osVersion.distribution", "osVersion.kernelRelease"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "coarse flag tokens this Connect does not know",
|
||||
"category": "unknown-field",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": [
|
||||
"cluster.degraded",
|
||||
"drive.offline",
|
||||
"rebalance.pending",
|
||||
"tier.transition.stalled"
|
||||
]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [
|
||||
"coarseFlags[rebalance.pending]",
|
||||
"coarseFlags[tier.transition.stalled]"
|
||||
],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "near-miss member names are unknown because matching is exact and case sensitive",
|
||||
"category": "unknown-field",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"],
|
||||
"RustfsVersion": "1.4.2",
|
||||
"capacityFreeBytes": 687194767360,
|
||||
"capacity_used_bytes": 412316860416,
|
||||
"nodeCounts": 8
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [
|
||||
"RustfsVersion",
|
||||
"capacityFreeBytes",
|
||||
"capacity_used_bytes",
|
||||
"nodeCounts"
|
||||
],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "inventory",
|
||||
"fixture": "valid-vectors",
|
||||
"description": "Accepted L0 inventory snapshots with their normalized form, canonical hash input, and content hash. canonicalJson is the exact byte string that is hashed after the domain prefix, so an independent implementation can diff its serialization before it ever compares a digest.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "fully populated snapshot",
|
||||
"category": "valid",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["cluster.degraded", "drive.offline"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unsorted duplicated and unknown coarse flags normalize to the same snapshot",
|
||||
"category": "valid",
|
||||
"sameContentHashAs": "fully populated snapshot",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "linux", "major": 6, "minor": 8 },
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"coarseFlags": ["drive.offline", "cluster.degraded", "drive.offline", "cluster.on.fire"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": ["coarseFlags[cluster.on.fire]"],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":[\"cluster.degraded\",\"drive.offline\"],\"driveCount\":96,\"nodeCount\":8,\"osVersion\":{\"family\":\"linux\",\"major\":6,\"minor\":8},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "e1cba9f30a26b94bb289a8a4b630a704dafaeb4960bffd38cef98e3d2581518f",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "every limit at its maximum",
|
||||
"category": "valid",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "9999.9999.9999",
|
||||
"osVersion": { "family": "other", "major": 9999, "minor": 9999 },
|
||||
"nodeCount": 4096,
|
||||
"driveCount": 1048576,
|
||||
"capacityTotalBytes": 9007199254740991,
|
||||
"capacityUsedBytes": 9007199254740991,
|
||||
"coarseFlags": [
|
||||
"capacity.critical",
|
||||
"capacity.warning",
|
||||
"clock.skew",
|
||||
"cluster.degraded",
|
||||
"cluster.healing",
|
||||
"cluster.readonly",
|
||||
"drive.offline",
|
||||
"node.offline"
|
||||
]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":9007199254740991,\"capacityUsedBytes\":9007199254740991,\"coarseFlags\":[\"capacity.critical\",\"capacity.warning\",\"clock.skew\",\"cluster.degraded\",\"cluster.healing\",\"cluster.readonly\",\"drive.offline\",\"node.offline\"],\"driveCount\":1048576,\"nodeCount\":4096,\"osVersion\":{\"family\":\"other\",\"major\":9999,\"minor\":9999},\"rustfsVersion\":\"9999.9999.9999\"}",
|
||||
"contentHash": "b69d51f898a53562a7057faa5bda1e21699ee6615517cd8b1455096f9171bce4",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "every limit at its minimum",
|
||||
"category": "valid",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "0.0.0",
|
||||
"osVersion": null,
|
||||
"nodeCount": 1,
|
||||
"driveCount": 0,
|
||||
"capacityTotalBytes": 0,
|
||||
"capacityUsedBytes": 0,
|
||||
"coarseFlags": []
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":0,\"capacityUsedBytes\":0,\"coarseFlags\":[],\"driveCount\":0,\"nodeCount\":1,\"osVersion\":null,\"rustfsVersion\":\"0.0.0\"}",
|
||||
"contentHash": "08ebc03ee906c05d686ef32c0998154213f2811a2d07408a9ef7d0b205cc701b",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a full cluster reports used equal to total",
|
||||
"category": "valid",
|
||||
"input": {
|
||||
"protocolVersion": "v1",
|
||||
"rustfsVersion": "1.4.2",
|
||||
"osVersion": { "family": "freebsd", "major": 14, "minor": 1 },
|
||||
"nodeCount": 4,
|
||||
"driveCount": 32,
|
||||
"capacityTotalBytes": 549755813888,
|
||||
"capacityUsedBytes": 549755813888,
|
||||
"coarseFlags": ["capacity.critical", "cluster.readonly"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"reason": null,
|
||||
"httpStatus": null,
|
||||
"retained": [
|
||||
"protocolVersion",
|
||||
"rustfsVersion",
|
||||
"osVersion",
|
||||
"nodeCount",
|
||||
"driveCount",
|
||||
"capacityTotalBytes",
|
||||
"capacityUsedBytes",
|
||||
"coarseFlags"
|
||||
],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"canonicalJson": "{\"capacityTotalBytes\":549755813888,\"capacityUsedBytes\":549755813888,\"coarseFlags\":[\"capacity.critical\",\"cluster.readonly\"],\"driveCount\":32,\"nodeCount\":4,\"osVersion\":{\"family\":\"freebsd\",\"major\":14,\"minor\":1},\"rustfsVersion\":\"1.4.2\"}",
|
||||
"contentHash": "0ab2308e84d2a9660650526edcd07795fe08a0fea047f16718846ce883a150b3",
|
||||
"echoedBack": [],
|
||||
"stored": [],
|
||||
"logged": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
5133761d19d6a64c18b6b5f871d646f6a2da4ceccc998d3cf7e22f692ca2d925 accept-vectors.json
|
||||
c7da10d173e7fafa112743d9a41e2bc94df58bf88a0542d80350b74da8f382a5 error-codes.json
|
||||
e98cfbedfb385defdaa9d001c85fdebcf9df2b4d054930951ff59dfa1385e52f reject-vectors.json
|
||||
69d43c8266d7bb29b4df7105c49250293943583f2202b93d922d9a924fca0c09 trust-chain.json
|
||||
e60cfca04bf0ce2f69495c49a95e4cc42e92e8114f6ad43449084527b06a0939 trust-model.json
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "offline-enrollment",
|
||||
"fixture": "accept-vectors",
|
||||
"description": "Offline enrollment artifacts that verify. evaluationTime is the verifier clock the vector is evaluated at; the artifact bytes are frozen, so a window is a property of the evaluation and not of the bytes.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "challenge signed by a chained signing key under the pinned root",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIlRWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"chainVerifies": true,
|
||||
"rootPinned": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge evaluated 120 seconds before its issuedAt is inside the skew tolerance",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-16T23:58:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIlRWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"chainVerifies": true,
|
||||
"rootPinned": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge evaluated 300 seconds after its expiresAt is still inside the skew tolerance",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-24T00:05:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIlRWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"chainVerifies": true,
|
||||
"rootPinned": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response binding the device public key and the challenge proof",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogInB3bUwxVW9jSG5NYVUwa2w2ZVc0M3BfcllOakxBOGtJaUN2TDlibktwUGMiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "n4joB8c1KbYvw7MSjeGs1BEYeYe8dFpy47Me_iD7MO1gUSKDpGl6MyCxuZ8KWmjzNMUWz1sgREEW8HsgpNlsIQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"devicePublicKeyIsTheVerifyingKey": true,
|
||||
"challengeProofMatches": true,
|
||||
"organizationMatches": true,
|
||||
"clusterMatches": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response carrying an unknown optional field is accepted and the field is discarded",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIm5fYWVJb0ZkeldyRnppWjZ4b1BManBobnhaZVB5US1YaTdQSzRYUFhDNUEiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiLAogICAgInRlbGVtZXRyeUhpbnQiOiAiaWdub3JlZCIKfQo=",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "tdMIDSpMk2kcZKT1FC8e3TDxNwujk8CVCgw7c3Np3ols8nUMx5Hx-TuznC57lqEq0Yo08H4r4AaztSktISHbXQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"devicePublicKeyIsTheVerifyingKey": true,
|
||||
"challengeProofMatches": true,
|
||||
"organizationMatches": true,
|
||||
"clusterMatches": true,
|
||||
"withinWindow": true,
|
||||
"accepted": true,
|
||||
"reason": null,
|
||||
"discardedFields": [
|
||||
"telemetryHint"
|
||||
],
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "offline-enrollment",
|
||||
"fixture": "error-codes",
|
||||
"description": "Frozen ErrorInfo reasons for offline enrollment. Clients branch on status and reason, never on message.",
|
||||
"domain": "rustfs.connect",
|
||||
"detailType": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"disclosureRules": [
|
||||
"A rejection never reveals whether a presented key or challenge belongs to another tenant.",
|
||||
"A rejection never contains key material, signature octets, nonces, or document bytes.",
|
||||
"A rejection never reports which of several failed checks failed first beyond the single frozen reason."
|
||||
],
|
||||
"reasons": [
|
||||
{
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The protocolVersion is missing, malformed, or names an unsupported major version. Identical to the rule frozen in protocol/agent/v1/authentication.md."
|
||||
},
|
||||
{
|
||||
"reason": "UNSUPPORTED_FORMAT",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The formatVersion is not one of the frozen supported format versions."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The signature is not 64 octets of fixed-width r||s in unpadded base64url, or r or s is out of range."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_NOT_CANONICAL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The signature is well formed and verifies, but its s exceeds half the group order. Only the low-S form is accepted."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "ECDSA verification over the received octets failed. The document was altered, or it was signed by another key."
|
||||
},
|
||||
{
|
||||
"reason": "ENROLLMENT_ROOT_UNKNOWN",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The first trust link is issued by a key that is not pinned in this build. There is no path from this to acceptance: the root is never learned."
|
||||
},
|
||||
{
|
||||
"reason": "TRUST_CHAIN_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "A trust link failed its own signature check, named the wrong issuer, carried an unknown role, or was outside its validity at the challenge issuedAt."
|
||||
},
|
||||
{
|
||||
"reason": "CONNECT_KEY_UNCHAINED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The challenge connectKeyId is not the subject of the last trust link, so nothing under the pinned root vouches for the signing key."
|
||||
},
|
||||
{
|
||||
"reason": "CHALLENGE_UNKNOWN",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "Connect has no issued challenge with this challengeId."
|
||||
},
|
||||
{
|
||||
"reason": "CHALLENGE_NOT_YET_VALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The evaluation time is more than the skew tolerance before issuedAt."
|
||||
},
|
||||
{
|
||||
"reason": "CHALLENGE_EXPIRED",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The evaluation time is more than the skew tolerance after expiresAt."
|
||||
},
|
||||
{
|
||||
"reason": "CHALLENGE_PROOF_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The response nonce or challengeProof is not the one Connect issued for this challenge."
|
||||
},
|
||||
{
|
||||
"reason": "DEVICE_PROOF_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The response signature does not verify under the devicePublicKey it presents, or deviceKeyId is not that key fingerprint. Proof of possession failed."
|
||||
},
|
||||
{
|
||||
"reason": "ENROLLMENT_REPLAYED",
|
||||
"httpStatus": 409,
|
||||
"status": "ABORTED",
|
||||
"meaning": "The challenge was already consumed. A challenge is single use even when the replayed response is byte identical."
|
||||
},
|
||||
{
|
||||
"reason": "ORGANIZATION_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"meaning": "The response names a different organization than the challenge it answers."
|
||||
},
|
||||
{
|
||||
"reason": "CLUSTER_MISMATCH",
|
||||
"httpStatus": 403,
|
||||
"status": "PERMISSION_DENIED",
|
||||
"meaning": "The response names a different cluster than the challenge it answers."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "offline-enrollment",
|
||||
"fixture": "reject-vectors",
|
||||
"description": "Offline enrollment artifacts that must never be accepted. signatureVerifies records whether the raw ECDSA verification over the received octets succeeds, so a vector that fails only on a rule beyond the mathematics is visibly distinct from a forgery.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "tampered challenge bytes with the original signature",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIkFWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": false,
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chain rooted at a key that is not pinned in the build",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "rogueSigning",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIjBZcVFTc2xEYlc5Nm5fcmQ4M1dKQllmX1RSWTNoZkh2WHFCRHkwWVdMY00iLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICI5MGYzOGEwZGMyYzVmOTQ4ZjQ3ODg3YTAxMGVhM2NiOWU0MjVkMDQwOWVkYjJhNDAxNGM3Zjc1MzliYzIyNDcxIiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpTVROalpHSTNORGhpTVRoak1ETmlNakEwTnpCalpqWTJZVEprWWpaaFpqZ2lMQW9nSUNBZ0luSnZiR1VpT2lBaWMybG5ibWx1WnlJc0NpQWdJQ0FpYVhOemRXVnlTMlY1U1dRaU9pQWlOV1ptTXpjNU1UQmhZVFJrTmprNU5EbGxNbU0wT0RobU9UaGtOakEzTW1ZeE1HRXpZek5sTnpOa056YzJOams0T1RZek9EY3lOVGd5TmpRMFpqY3pNU0lzQ2lBZ0lDQWljM1ZpYW1WamRFdGxlVWxrSWpvZ0lqa3daak00WVRCa1l6SmpOV1k1TkRobU5EYzRPRGRoTURFd1pXRXpZMkk1WlRReU5XUXdOREE1WldSaU1tRTBNREUwWXpkbU56VXpPV0pqTWpJME56RWlMQW9nSUNBZ0luTjFZbXBsWTNSUWRXSnNhV05MWlhraU9pQWlRa2xIVG01elQzRk5Ra2swVEZCRU9FcDVVMmMzTjBVemRrTm9TVGM0VWpSQlNHZHZiSGhHWTBwT2REbGFPVWR6VFc5V1MwbGxPVnBQWkVwUFJUTTRjMFphZEROdlJqVmpjbFV3U0hGa2VtbEhabWxGY0RsVklpd0tJQ0FnSUNKdWIzUkNaV1p2Y21VaU9pQWlNakF5Tmkwd09DMHdNVlF3TURvd01Eb3dNRm9pTEFvZ0lDQWdJbTV2ZEVGbWRHVnlJam9nSWpJd01qWXRNRGd0TXpGVU1EQTZNREE2TURCYUlncDlDZz09IiwKICAgICAgICAgICAgInNpZ25hdHVyZSI6IHsKICAgICAgICAgICAgICAgICJhbGdvcml0aG0iOiAiRVMyNTYiLAogICAgICAgICAgICAgICAgImtleUlkIjogIjVmZjM3OTEwYWE0ZDY5OTQ5ZTJjNDg4Zjk4ZDYwNzJmMTBhM2MzZTczZDc3NjY5ODk2Mzg3MjU4MjY0NGY3MzEiLAogICAgICAgICAgICAgICAgInZhbHVlIjogInN1eFViWjlJczFsaVFsaGpmd1hRb1RlemU0bWU5ZjdRZGRyMzI5OWxtenBuLThWekgya2dUQTVZdk05QmktYjRBVGVITkxQQm1rRkRoYVZveFg2NEx3IgogICAgICAgICAgICB9CiAgICAgICAgfQogICAgXQp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "90f38a0dc2c5f948f47887a010ea3cb9e425d0409edb2a4014c7f7539bc22471",
|
||||
"value": "WcptrTFWDSY9WhA9Lu8U-OtJZtqroeoPypZuev4S3eEvEN1L1GsAGfYAAcF9LkHUO10WI6c7NVurZdtSzoU56g"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"rootPinned": false,
|
||||
"accepted": false,
|
||||
"reason": "ENROLLMENT_ROOT_UNKNOWN"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "signing link expired before the challenge was issued",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIjd4UWJEQUREbTc3NGJCZzhFc2tfUnhDajFrRUdjaTJOOE8yUmVLSGdNNlUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlZMkV3T1dZelpUVmhaRGt4T1RFd05tUXhNalpqT0RSbFpHUmpORFZpWlRnaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3Tmkwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EY3RNREZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiU1g3NVdkT1ZMdnBoUk1jY2UweHE3VzUtRXIwSFNWaEJjc1VuZzFVdjhSQVBSMjNwckNINW9KT0YwNkFfaDd4SEFqOVhMN1lldXhSU3RQc19wa1ROckEiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "xFOTJIOO0sMTsbCNGeNF31-A7R1aVyPWDx06qOuvb2BwTGNwARo95z-3zcPsZ68TSrKIocNbG5KRd3jM9zeVNQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"chainVerifies": false,
|
||||
"accepted": false,
|
||||
"reason": "TRUST_CHAIN_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge signed by a key the chain does not name",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "stray",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogInNXZXhMdUV5MVRtQmVydnpSX1g3SlBLa3BBdnNlM051eU10NUJIZmpqNGsiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICJmNmZiZTA1MGRlZmRlZDE4YjUwNDc3YWNlMzhjOTUxNWZiNjFiODE1N2U1N2IyZjBlN2U4Y2E2OWM4NjJiNmNhIiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "f6fbe050defded18b50477ace38c9515fb61b8157e57b2f0e7e8ca69c862b6ca",
|
||||
"value": "FoWcvh5OA-_Vm7bCTf_TQuw2oGq5lOwjpVdfY45fQRAy9-TvHBCRr7Z1x4QC_5bjjt_hbled0dVm6ekfpC2dvw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"connectKeyChained": false,
|
||||
"accepted": false,
|
||||
"reason": "CONNECT_KEY_UNCHAINED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge evaluated 301 seconds past its expiresAt",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-24T00:05:01Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIlRWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"withinWindow": false,
|
||||
"accepted": false,
|
||||
"reason": "CHALLENGE_EXPIRED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge evaluated 301 seconds before its issuedAt",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-16T23:54:59Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIlRWeDRqS2FQZ1dQbGRUU203X1N6S1BsSDVIZmhrcHFxcjlieWVDalhkSFUiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"withinWindow": false,
|
||||
"accepted": false,
|
||||
"reason": "CHALLENGE_NOT_YET_VALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge declaring an unknown formatVersion",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzIiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MSIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogInp4Y2VGd1hVSkFlVGQyWE5yZVp6bElNVEhMX1RGa1ROd2FjUWNsaWJta0UiLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "qfe91lyiqI8ICHxDLyGjf34dIWTD2D8xtv9SH0Najv05g8VDKHTesAsmp9wbp0RvEHRm8Zh0gylkgsZnNUM7-w"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_FORMAT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "challenge declaring an unsupported protocol major version",
|
||||
"artifact": "challenge",
|
||||
"signerKey": "signing",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLAogICAgInByb3RvY29sVmVyc2lvbiI6ICJ2MiIsCiAgICAiY2hhbGxlbmdlSWQiOiAiMDE5OGYzYTEtN2YwMC03ZDQwLWJlNTEtM2Y0YTViNmM3ZDgzIiwKICAgICJvcmdhbml6YXRpb25OYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwIiwKICAgICJjbHVzdGVyTmFtZSI6ICJvcmdhbml6YXRpb25zLzAxOThmM2ExLTRjMDAtN2ExMC04YjIxLTBjMWQyZTNmNGE1MC9jbHVzdGVycy8wMTk4ZjNhMS01ZDAwLTdiMjAtOWMzMS0xZDJlM2Y0YTViNjEiLAogICAgIm5vbmNlIjogIkh4WEY1b3lGR3JFdmIyRklRTUhXeXpGSVVHd3ZMeVhvYkJGOENXUnIwNnciLAogICAgImlzc3VlZEF0IjogIjIwMjYtMDgtMTdUMDA6MDA6MDBaIiwKICAgICJleHBpcmVzQXQiOiAiMjAyNi0wOC0yNFQwMDowMDowMFoiLAogICAgImNvbm5lY3RLZXlJZCI6ICIwOGU3Mjk1YzhmOWQwNDNlMjJiMmI4MGZkYjE0ODBiMGJlYzA2MGRhY2JjZTdkZTlkZDJlM2Q1ODNmOTNkN2U4IiwKICAgICJ0cnVzdENoYWluIjogWwogICAgICAgIHsKICAgICAgICAgICAgImJ5dGVzIjogImV3b2dJQ0FnSW1admNtMWhkRlpsY25OcGIyNGlPaUFpY25WemRHWnpMbU52Ym01bFkzUXViMlptYkdsdVpTNTBjblZ6ZEV4cGJtc3ZNU0lzQ2lBZ0lDQWljSEp2ZEc5amIyeFdaWEp6YVc5dUlqb2dJbll4SWl3S0lDQWdJQ0p6WlhKcFlXd2lPaUFpWXpJMU9HRTJaR1JqT1RoaVlqVmhOV1U1TkRSbU5qSmxNRFkzT1dFM05HVWlMQW9nSUNBZ0luSnZiR1VpT2lBaWFXNTBaWEp0WldScFlYUmxJaXdLSUNBZ0lDSnBjM04xWlhKTFpYbEpaQ0k2SUNKa1pqSXlaVEk0TURZeE1USmtaV0ppWlRrMU16WTNNbUZoWm1FeE9EWmtOams1WVdZd1pUazNaR1F6Wm1ReVlqQTVabUU0TXpVNU1EQTFabVV6TkRobUlpd0tJQ0FnSUNKemRXSnFaV04wUzJWNVNXUWlPaUFpTURSbFltTTNOR1F6TURBeU1HWTNNamM1T0Roak9UWmpNbVptWWpJeU9ESXhPRGd6WldVMllqSmxOR014TVRkbU5tVTVOVGxrWW1RMk0yWXpNMlE0TnlJc0NpQWdJQ0FpYzNWaWFtVmpkRkIxWW14cFkwdGxlU0k2SUNKQ1JIZzFWbk5YU1ZwS1YzcERTMkZVTURoZmVGTklhR2RoTFdsek9UVmhXVTlvTUcxa1FUVTBOMll5UVd4R1ZFeG1lV1ZhYWpBeGVtVk9hV3ROZFdSalFXWk1WSGcwUkVoWVJHRkhjM0ZRUkZSWmRGWjZSRzhpTEFvZ0lDQWdJbTV2ZEVKbFptOXlaU0k2SUNJeU1ESTJMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNDaUFnSUNBaWJtOTBRV1owWlhJaU9pQWlNakF5Tnkwd01TMHdNVlF3TURvd01Eb3dNRm9pQ24wSyIsCiAgICAgICAgICAgICJzaWduYXR1cmUiOiB7CiAgICAgICAgICAgICAgICAiYWxnb3JpdGhtIjogIkVTMjU2IiwKICAgICAgICAgICAgICAgICJrZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICAgICAgICAgICAgICJ2YWx1ZSI6ICJsWEl4RG1rdlJYMkNLTF9OU01KX3ltLUhKWTQ4cW9VejRoM2JQUHgzM0lGRXYwRTFnblNPV2QxTTJYcHJtSVZSQ3Z4OHhONUNmWUowTDd3enNEQV9GUSIKICAgICAgICAgICAgfQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgICAiYnl0ZXMiOiAiZXdvZ0lDQWdJbVp2Y20xaGRGWmxjbk5wYjI0aU9pQWljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNDaUFnSUNBaWNISnZkRzlqYjJ4V1pYSnphVzl1SWpvZ0luWXhJaXdLSUNBZ0lDSnpaWEpwWVd3aU9pQWlOMkptWm1ObU1EYzRaRGMyTkRnNU9XRTNNR1prWWpGaFpqbGlZbU5rTmpJaUxBb2dJQ0FnSW5KdmJHVWlPaUFpYzJsbmJtbHVaeUlzQ2lBZ0lDQWlhWE56ZFdWeVMyVjVTV1FpT2lBaU1EUmxZbU0zTkdRek1EQXlNR1kzTWpjNU9EaGpPVFpqTW1abVlqSXlPREl4T0RnelpXVTJZakpsTkdNeE1UZG1ObVU1TlRsa1ltUTJNMll6TTJRNE55SXNDaUFnSUNBaWMzVmlhbVZqZEV0bGVVbGtJam9nSWpBNFpUY3lPVFZqT0dZNVpEQTBNMlV5TW1JeVlqZ3dabVJpTVRRNE1HSXdZbVZqTURZd1pHRmpZbU5sTjJSbE9XUmtNbVV6WkRVNE0yWTVNMlEzWlRnaUxBb2dJQ0FnSW5OMVltcGxZM1JRZFdKc2FXTkxaWGtpT2lBaVFrZFdTVlUzZVRWU2FXZ3lhRWs0TFZCZmFXd3RSM1ZJVm5Sa1VVeEdlVEpFYUZGbFJsVTNjV2g1WW1KME1qa3hlbE5DYTE5MWVGbEtTazVoUWtSa1pEbDNUVU0wUkdac1dUVlJRbEJVT0cxU05qZEZaMk5CSWl3S0lDQWdJQ0p1YjNSQ1pXWnZjbVVpT2lBaU1qQXlOaTB3T0Mwd01WUXdNRG93TURvd01Gb2lMQW9nSUNBZ0ltNXZkRUZtZEdWeUlqb2dJakl3TWpZdE1EZ3RNekZVTURBNk1EQTZNREJhSWdwOUNnPT0iLAogICAgICAgICAgICAic2lnbmF0dXJlIjogewogICAgICAgICAgICAgICAgImFsZ29yaXRobSI6ICJFUzI1NiIsCiAgICAgICAgICAgICAgICAia2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAgICAgICAgICAgICAidmFsdWUiOiAiamhrZFozeWpuOC1Od0JxWjZjRUtZVlNsYV9VblVmVFJWVmNxazZJSndUTWx3S1ZyWU8xWEZSVTA1bTBWc0VNMThYM1ppOE96bGxoSVAyUjlxWUMwa0EiCiAgICAgICAgICAgIH0KICAgICAgICB9CiAgICBdCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"value": "yR9NE0AkqEwQT23IGbGMbM377H6d7NEuLIgWSTRpsRFFKPDjZxIeMpYnSgxjAxJwSxl3CTwwfHubEti4oVaRng"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_PROTOCOL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response naming another organization than the challenge it answers",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRiNjAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogInpldXU4dVlpZDZ1cl9fa2hrMk9YNDJwaHdxUmttTXVEVXpXeUhSYkk5YW8iLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "Lgs5XNYe0XtZDTSLHpv5cxpCLzcZOgXzhJiQUMGBxb8aYng-d1B38yGJndmosm7ZWisFUW_fU7jHYRRgWroX1A"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"organizationMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "ORGANIZATION_MISMATCH"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response naming another cluster than the challenge it answers",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YjYwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWM3MiIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIlpNZkc1SUNmWkRfSnNXelVUemk2aGprTnpKRnpTV1dZeWJYNFJEMnBIRkUiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "6sWuGJE0FXKPBhuyECUews-X6pfM9YDsTJ3ru8Pcljh5M1qPvoJBcvQ78KbwO7A-vYK4eS_s-7XFQ-YvFzCCwA"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"clusterMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "CLUSTER_MISMATCH"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response echoing a nonce the challenge never carried",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiRlRpcHJ0bzF1eVpBZDJIbGV1eXFtbGtaUFpzaGJ3S3picHlqbGFuQnpBNCIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIlA4VDhPemJzZFhTQ19RU25SaVNtU0RZakZIajMyTjVXZjJ1ZHF4ejVDakUiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "CBOfTV8YB8DHBNLN9KOndRjRBT295jVkdaqCZUmExUEomiMRpmM24sOuOHwDNzqBH-oJioRwpDeiVxvAe8cBOg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"challengeNonceMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "CHALLENGE_PROOF_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response carrying a proof taken from a different challenge",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiRm9XY3ZoNU9BLV9WbTdiQ1RmX1RRdXcyb0dxNWxPd2pwVmRmWTQ1ZlFSQXk5LVR2SEJDUnI3WjF4NFFDXzViamp0X2hibGVkMGRWbTZla2ZwQzJkdnciLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIm8wdnh5Sm03cklHVjB1eGd1WGtaMmFfaHBXT1RmSEY3dlkwUVBRcTVicE0iLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "K6uiu35Fy9ImZ3LdsIHg_avCJedTk1NqsBxqcQ5q8rocD_Fx_-kzzERCtyIgxHmivljeQf5BGaevJP-BBhR_FQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"challengeProofMatches": false,
|
||||
"accepted": false,
|
||||
"reason": "CHALLENGE_PROOF_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response signed by a key other than the device public key it presents",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "foreignDevice",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIm9qUzBudHFIT3BKMzA3WUhIdlg5OUZ0U2tjb1lZR18zQVZpSldGUFZ4UzQiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "-mAQtMW6JTIndESRTbUIYMBWIkEM_xF8EEWSHfSljXwPOgEJ_59wdsnmfCsIKmWlALjtJRjWZUsqKamERJSLdg"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": false,
|
||||
"devicePublicKeyIsTheVerifyingKey": false,
|
||||
"accepted": false,
|
||||
"reason": "DEVICE_PROOF_INVALID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response produced 301 seconds after the challenge expiry tolerance",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-24T00:06:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIjNwYTRMRVlFeVpSX0t2cXJ2ZndrUllWWGpDVkMzNFN6TkdiWW9rcl95aWMiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yNFQwMDowNTowMVoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "W-CScIVVVsLmQtopMmZEYt2T0L9wwgCsdBVADpbSMPIOsKv6VwObaCTorGVWcH1rs6nvy4UTQUyNkBIIC9U8Bw"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"withinWindow": false,
|
||||
"accepted": false,
|
||||
"reason": "CHALLENGE_EXPIRED"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response declaring an unknown formatVersion",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMiIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIlAxVWdKaUZrbXpKd1VRMzhfMmJKR2pkbC02Ri1UQ1F4akUzZzNqRDNVZzAiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "XYPw4wwC36mBORLmzLRjRJzaY4gU7KaCJDcYY0MC6klPPX9ackSYDFEjRYo1I-qsYHAmCu3iTdbxul6CZ-CwQQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_FORMAT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "response declaring an unsupported protocol major version",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:00Z",
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYyIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogIm85U0RpUzk0YjFBdkFCNE1waTB2VnBPQnVkd054VlZlT3NBMGZCai1LdUUiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "ypN6eGOCWi0efKh-hkv_LmwX1pU8WNG0AG8PbZFlqvhGBPO2JFxhvyLz2BlI3rH7U7FBvbebjnlNOanf1blAzQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_PROTOCOL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a byte identical replay of an accepted response",
|
||||
"artifact": "response",
|
||||
"answersChallenge": "challenge signed by a chained signing key under the pinned root",
|
||||
"signerKey": "device",
|
||||
"evaluationTime": "2026-08-20T12:00:01Z",
|
||||
"challengeAlreadyConsumed": true,
|
||||
"document": {
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50UmVzcG9uc2UvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJjaGFsbGVuZ2VJZCI6ICIwMTk4ZjNhMS03ZjAwLTdkNDAtYmU1MS0zZjRhNWI2YzdkODMiLAogICAgIm9yZ2FuaXphdGlvbk5hbWUiOiAib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAiLAogICAgImNsdXN0ZXJOYW1lIjogIm9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MSIsCiAgICAiY2hhbGxlbmdlTm9uY2UiOiAiVFZ4NGpLYVBnV1BsZFRTbTdfU3pLUGxINUhmaGtwcXFyOWJ5ZUNqWGRIVSIsCiAgICAiY2hhbGxlbmdlUHJvb2YiOiAiamlEVjRXeTgxV3dxUXdseFZxRjBlRlRoOWpFTW5EM21rVVdCNVhxR21wa1N3MFduVGRBanVWSDF3cVJNZVVFWWtaTERUdTdmQjVZSlJ2Qk4yd3JVdEEiLAogICAgImRldmljZUtleUlkIjogIjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YiLAogICAgImRldmljZVB1YmxpY0tleSI6ICJCT1R1eUp6RkhqVzQyaFJsckNZM2JtTUJLMWdmdFQzYU5MaVRnQ3NKMV9lTlNkcjFHTTlhWTh3Mzlwb3BWTG9lZHdvZUFHQUJncDYxaEVhd2tFS3FJUDAiLAogICAgImRldmljZU5vbmNlIjogInB3bUwxVW9jSG5NYVUwa2w2ZVc0M3BfcllOakxBOGtJaUN2TDlibktwUGMiLAogICAgInByb2R1Y2VkQXQiOiAiMjAyNi0wOC0yMFQxMjowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"value": "n4joB8c1KbYvw7MSjeGs1BEYeYe8dFpy47Me_iD7MO1gUSKDpGl6MyCxuZ8KWmjzNMUWz1sgREEW8HsgpNlsIQ"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"signatureVerifies": true,
|
||||
"accepted": false,
|
||||
"reason": "ENROLLMENT_REPLAYED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "offline-enrollment",
|
||||
"fixture": "trust-chain",
|
||||
"description": "The golden trust chain. bytes fields are standard padded base64 (RFC 4648 section 4) of the exact raw octets of the signed document; signature.value is unpadded base64url (RFC 4648 section 5) of the 64 octet r||s. No private key appears here or anywhere else in this repository: the vectors are for verification conformance, and a producer proves itself with the encoding rules rather than by reproducing these bytes.",
|
||||
"pinnedRoot": {
|
||||
"keyId": "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
|
||||
"publicKey": "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s",
|
||||
"source": "compiled into official RustFS builds"
|
||||
},
|
||||
"chain": [
|
||||
{
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJzZXJpYWwiOiAiYzI1OGE2ZGRjOThiYjVhNWU5NDRmNjJlMDY3OWE3NGUiLAogICAgInJvbGUiOiAiaW50ZXJtZWRpYXRlIiwKICAgICJpc3N1ZXJLZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICJzdWJqZWN0S2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAic3ViamVjdFB1YmxpY0tleSI6ICJCRHg1VnNXSVpKV3pDS2FUMDhfeFNIaGdhLWlzOTVhWU9oMG1kQTU0N2YyQWxGVExmeWVaajAxemVOaWtNdWRjQWZMVHg0REhYRGFHc3FQRFRZdFZ6RG8iLAogICAgIm5vdEJlZm9yZSI6ICIyMDI2LTAxLTAxVDAwOjAwOjAwWiIsCiAgICAibm90QWZ0ZXIiOiAiMjAyNy0wMS0wMVQwMDowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
|
||||
"value": "lXIxDmkvRX2CKL_NSMJ_ym-HJY48qoUz4h3bPPx33IFEv0E1gnSOWd1M2XprmIVRCvx8xN5CfYJ0L7wzsDA_FQ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJzZXJpYWwiOiAiN2JmZmNmMDc4ZDc2NDg5OWE3MGZkYjFhZjliYmNkNjIiLAogICAgInJvbGUiOiAic2lnbmluZyIsCiAgICAiaXNzdWVyS2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAic3ViamVjdEtleUlkIjogIjA4ZTcyOTVjOGY5ZDA0M2UyMmIyYjgwZmRiMTQ4MGIwYmVjMDYwZGFjYmNlN2RlOWRkMmUzZDU4M2Y5M2Q3ZTgiLAogICAgInN1YmplY3RQdWJsaWNLZXkiOiAiQkdWSVU3eTVSaWgyaEk4LVBfaWwtR3VIVnRkUUxGeTJEaFFlRlU3cWh5YmJ0MjkxelNCa191eFlKSk5hQkRkZDl3TUM0RGZsWTVRQlBUOG1SNjdFZ2NBIiwKICAgICJub3RCZWZvcmUiOiAiMjAyNi0wOC0wMVQwMDowMDowMFoiLAogICAgIm5vdEFmdGVyIjogIjIwMjYtMDgtMzFUMDA6MDA6MDBaIgp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "04ebc74d30020f727988c96c2ffb22821883ee6b2e4c117f6e959dbd63f33d87",
|
||||
"value": "jhkdZ3yjn8-NwBqZ6cEKYVSla_UnUfTRVVcqk6IJwTMlwKVrYO1XFRU05m0VsEM18X3Zi8OzllhIP2R9qYC0kA"
|
||||
}
|
||||
}
|
||||
],
|
||||
"keys": [
|
||||
{
|
||||
"role": "enrollment-root",
|
||||
"name": "root",
|
||||
"keyId": "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
|
||||
"publicKey": "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s"
|
||||
},
|
||||
{
|
||||
"role": "intermediate",
|
||||
"name": "intermediate",
|
||||
"keyId": "04ebc74d30020f727988c96c2ffb22821883ee6b2e4c117f6e959dbd63f33d87",
|
||||
"publicKey": "BDx5VsWIZJWzCKaT08_xSHhga-is95aYOh0mdA547f2AlFTLfyeZj01zeNikMudcAfLTx4DHXDaGsqPDTYtVzDo"
|
||||
},
|
||||
{
|
||||
"role": "signing",
|
||||
"name": "signing",
|
||||
"keyId": "08e7295c8f9d043e22b2b80fdb1480b0bec060dacbce7de9dd2e3d583f93d7e8",
|
||||
"publicKey": "BGVIU7y5Rih2hI8-P_il-GuHVtdQLFy2DhQeFU7qhybbt291zSBk_uxYJJNaBDdd9wMC4DflY5QBPT8mR67EgcA"
|
||||
},
|
||||
{
|
||||
"role": "unchained",
|
||||
"name": "stray",
|
||||
"keyId": "f6fbe050defded18b50477ace38c9515fb61b8157e57b2f0e7e8ca69c862b6ca",
|
||||
"publicKey": "BMDdRSCtFB2w1c3buqktv-eGgMJeck5-rYvnTlvvfTuqg2NybM5gZLrnJCasieiR48JF-Sik-4UI_HCQM12ogsA"
|
||||
},
|
||||
{
|
||||
"role": "unpinned-root",
|
||||
"name": "rogueRoot",
|
||||
"keyId": "5ff37910aa4d69949e2c488f98d6072f10a3c3e73d776698963872582644f731",
|
||||
"publicKey": "BB4ldUQqSkfBQhYa10Otr2q43Yaka53dNLVD8nDThP_fVxFH_s04p_gIds6MDef11ukjZAhdgqQu_A8JLW3SzVk"
|
||||
},
|
||||
{
|
||||
"role": "unpinned-signing",
|
||||
"name": "rogueSigning",
|
||||
"keyId": "90f38a0dc2c5f948f47887a010ea3cb9e425d0409edb2a4014c7f7539bc22471",
|
||||
"publicKey": "BIGNnsOqMBI4LPD8JySg77E3vChI78R4AHgolxFcJNt9Z9GsMoVKIe9ZOdJOE38sFZt3oF5crU0HqdziGfiEp9U"
|
||||
},
|
||||
{
|
||||
"role": "device",
|
||||
"name": "device",
|
||||
"keyId": "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf",
|
||||
"publicKey": "BOTuyJzFHjW42hRlrCY3bmMBK1gftT3aNLiTgCsJ1_eNSdr1GM9aY8w39popVLoedwoeAGABgp61hEawkEKqIP0"
|
||||
},
|
||||
{
|
||||
"role": "device",
|
||||
"name": "foreignDevice",
|
||||
"keyId": "63d8184f2b2ec6895bb71b999c90192d7718694ba042bad86364b27628f9cb50",
|
||||
"publicKey": "BMwdwp6a50zSVsIa3NluDPaszxyIm2EeIbdukH38O3etEhoAlHXJrRblAltumHYlku6EBa_S3IBen8T8JOrr6Nk"
|
||||
},
|
||||
{
|
||||
"role": "device",
|
||||
"name": "revokedDevice",
|
||||
"keyId": "1f6a0c684716862c2507b48e72de25fbcb7db5c7aa629ff0c17e20b8a387d7ad",
|
||||
"publicKey": "BA9MuDkIi4fZSw98wCnbuEZUxqheO5Uks4rVTZa477kg30lb-aLAk5b8xM3kgnEq6PNCkSc28x_JaBPwPkBmwYk"
|
||||
}
|
||||
],
|
||||
"alternateChains": [
|
||||
{
|
||||
"name": "chain rooted at an unpinned key",
|
||||
"chain": [
|
||||
{
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJzZXJpYWwiOiAiMTNjZGI3NDhiMThjMDNiMjA0NzBjZjY2YTJkYjZhZjgiLAogICAgInJvbGUiOiAic2lnbmluZyIsCiAgICAiaXNzdWVyS2V5SWQiOiAiNWZmMzc5MTBhYTRkNjk5NDllMmM0ODhmOThkNjA3MmYxMGEzYzNlNzNkNzc2Njk4OTYzODcyNTgyNjQ0ZjczMSIsCiAgICAic3ViamVjdEtleUlkIjogIjkwZjM4YTBkYzJjNWY5NDhmNDc4ODdhMDEwZWEzY2I5ZTQyNWQwNDA5ZWRiMmE0MDE0YzdmNzUzOWJjMjI0NzEiLAogICAgInN1YmplY3RQdWJsaWNLZXkiOiAiQklHTm5zT3FNQkk0TFBEOEp5U2c3N0UzdkNoSTc4UjRBSGdvbHhGY0pOdDlaOUdzTW9WS0llOVpPZEpPRTM4c0ZadDNvRjVjclUwSHFkemlHZmlFcDlVIiwKICAgICJub3RCZWZvcmUiOiAiMjAyNi0wOC0wMVQwMDowMDowMFoiLAogICAgIm5vdEFmdGVyIjogIjIwMjYtMDgtMzFUMDA6MDA6MDBaIgp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "5ff37910aa4d69949e2c488f98d6072f10a3c3e73d776698963872582644f731",
|
||||
"value": "suxUbZ9Is1liQlhjfwXQoTeze4me9f7Qddr3299lmzpn-8VzH2kgTA5YvM9Bi-b4ATeHNLPBmkFDhaVoxX64Lw"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reason": "ENROLLMENT_ROOT_UNKNOWN",
|
||||
"note": "Internally consistent and correctly signed. It fails only because its root is not pinned, which is exactly what trust on first use would have accepted."
|
||||
},
|
||||
{
|
||||
"name": "signing link already expired when the challenge was issued",
|
||||
"chain": [
|
||||
{
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJzZXJpYWwiOiAiYzI1OGE2ZGRjOThiYjVhNWU5NDRmNjJlMDY3OWE3NGUiLAogICAgInJvbGUiOiAiaW50ZXJtZWRpYXRlIiwKICAgICJpc3N1ZXJLZXlJZCI6ICJkZjIyZTI4MDYxMTJkZWJiZTk1MzY3MmFhZmExODZkNjk5YWYwZTk3ZGQzZmQyYjA5ZmE4MzU5MDA1ZmUzNDhmIiwKICAgICJzdWJqZWN0S2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAic3ViamVjdFB1YmxpY0tleSI6ICJCRHg1VnNXSVpKV3pDS2FUMDhfeFNIaGdhLWlzOTVhWU9oMG1kQTU0N2YyQWxGVExmeWVaajAxemVOaWtNdWRjQWZMVHg0REhYRGFHc3FQRFRZdFZ6RG8iLAogICAgIm5vdEJlZm9yZSI6ICIyMDI2LTAxLTAxVDAwOjAwOjAwWiIsCiAgICAibm90QWZ0ZXIiOiAiMjAyNy0wMS0wMVQwMDowMDowMFoiCn0K",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
|
||||
"value": "lXIxDmkvRX2CKL_NSMJ_ym-HJY48qoUz4h3bPPx33IFEv0E1gnSOWd1M2XprmIVRCvx8xN5CfYJ0L7wzsDA_FQ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"bytes": "ewogICAgImZvcm1hdFZlcnNpb24iOiAicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsCiAgICAicHJvdG9jb2xWZXJzaW9uIjogInYxIiwKICAgICJzZXJpYWwiOiAiY2EwOWYzZTVhZDkxOTEwNmQxMjZjODRlZGRjNDViZTgiLAogICAgInJvbGUiOiAic2lnbmluZyIsCiAgICAiaXNzdWVyS2V5SWQiOiAiMDRlYmM3NGQzMDAyMGY3Mjc5ODhjOTZjMmZmYjIyODIxODgzZWU2YjJlNGMxMTdmNmU5NTlkYmQ2M2YzM2Q4NyIsCiAgICAic3ViamVjdEtleUlkIjogIjA4ZTcyOTVjOGY5ZDA0M2UyMmIyYjgwZmRiMTQ4MGIwYmVjMDYwZGFjYmNlN2RlOWRkMmUzZDU4M2Y5M2Q3ZTgiLAogICAgInN1YmplY3RQdWJsaWNLZXkiOiAiQkdWSVU3eTVSaWgyaEk4LVBfaWwtR3VIVnRkUUxGeTJEaFFlRlU3cWh5YmJ0MjkxelNCa191eFlKSk5hQkRkZDl3TUM0RGZsWTVRQlBUOG1SNjdFZ2NBIiwKICAgICJub3RCZWZvcmUiOiAiMjAyNi0wNi0wMVQwMDowMDowMFoiLAogICAgIm5vdEFmdGVyIjogIjIwMjYtMDctMDFUMDA6MDA6MDBaIgp9Cg==",
|
||||
"signature": {
|
||||
"algorithm": "ES256",
|
||||
"keyId": "04ebc74d30020f727988c96c2ffb22821883ee6b2e4c117f6e959dbd63f33d87",
|
||||
"value": "SX75WdOVLvphRMcce0xq7W5-Er0HSVhBcsUng1Uv8RAPR23prCH5oJOF06A_h7xHAj9XL7YeuxRStPs_pkTNrA"
|
||||
}
|
||||
}
|
||||
],
|
||||
"reason": "TRUST_CHAIN_INVALID",
|
||||
"note": "notAfter is 2026-07-01T00:00:00Z and the challenge issuedAt is 2026-08-17T00:00:00Z."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "offline-enrollment",
|
||||
"fixture": "trust-model",
|
||||
"description": "The frozen offline trust model: how an air-gapped device and Connect authenticate signed artifacts to each other without a network, a certificate, or trust on first use. R05 (the RustFS CLI) and R07 (the bundle writer) implement against this file; api/tests/Feature/Diagnostics/OfflineTrustFixtureTest.php replays it.",
|
||||
"signature": {
|
||||
"signatureAlgorithm": "ES256",
|
||||
"curve": "P-256",
|
||||
"hash": "SHA-256",
|
||||
"signatureEncoding": "fixed-width-r-s",
|
||||
"signatureLengthBytes": 64,
|
||||
"signatureTransferEncoding": "base64url-unpadded",
|
||||
"signatureValuePattern": "^[A-Za-z0-9_-]{86}$",
|
||||
"lowSRequired": true,
|
||||
"groupOrder": "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551",
|
||||
"maxS": "7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8",
|
||||
"publicKeyEncoding": "sec1-uncompressed",
|
||||
"publicKeyLengthBytes": 65,
|
||||
"publicKeyTransferEncoding": "base64url-unpadded",
|
||||
"subjectPublicKeyInfoDerPrefix": "3059301306072a8648ce3d020106082a8648ce3d030107034200",
|
||||
"keyIdAlgorithm": "SHA-256",
|
||||
"keyIdOver": "DER SubjectPublicKeyInfo",
|
||||
"keyIdEncoding": "lowercase-hex",
|
||||
"keyIdPattern": "^[0-9a-f]{64}$",
|
||||
"documentTransferEncoding": "base64-padded"
|
||||
},
|
||||
"domainSeparation": {
|
||||
"rule": "signatureInput = domainSeparationTag || 0x00 || the exact raw octets of the signed document as transmitted",
|
||||
"separatorByte": "0x00",
|
||||
"tagEncoding": "US-ASCII, no terminator beyond the single 0x00 separator",
|
||||
"reserialisationPermitted": false,
|
||||
"canonicalisationPermitted": false,
|
||||
"note": "A verifier never parses, re-encodes, re-indents, reorders, or normalises a document before verifying it. It hashes the bytes it received. Parsing happens only after the signature over those exact bytes has verified.",
|
||||
"tags": {
|
||||
"trustLink": "rustfs-offline-trust-link-v1",
|
||||
"enrollmentChallenge": "rustfs-offline-enrollment-challenge-v1",
|
||||
"enrollmentResponse": "rustfs-offline-enrollment-response-v1",
|
||||
"supportBundleManifest": "rustfs-support-bundle-v1"
|
||||
}
|
||||
},
|
||||
"verifierMustReject": [
|
||||
"A signature that is not exactly 64 octets of fixed-width r||s.",
|
||||
"A DER or any other ASN.1 encoded signature, even when it decodes to the same r and s.",
|
||||
"A signature encoded with the standard base64 alphabet or with = padding.",
|
||||
"A signature whose r or s is zero, or is greater than or equal to the group order.",
|
||||
"A signature whose s is greater than half the group order, even though such a signature verifies mathematically. ECDSA is malleable and only the low-S form is a canonical artifact identity.",
|
||||
"An algorithm value other than ES256, including a downgrade to a hash other than SHA-256.",
|
||||
"A public key that is not a 65 octet uncompressed SEC1 point on P-256, and any compressed or hybrid point form.",
|
||||
"A keyId that is not the lowercase SHA-256 hex of the DER SubjectPublicKeyInfo built from the accompanying public key.",
|
||||
"A signature checked against re-serialised, re-indented, key-reordered, or otherwise regenerated document bytes rather than the received octets.",
|
||||
"A document that verifies under one domain separation tag being accepted for another artifact type."
|
||||
],
|
||||
"rejectedSignatureEncodings": [
|
||||
{
|
||||
"name": "high-S signature over an otherwise valid challenge",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpntPLpXsi_cR64KPVuzhr7nK1Q3Xrg4lu7qctp1IVhQnQ",
|
||||
"acceptedByALenientVerifier": true,
|
||||
"reason": "SIGNATURE_NOT_CANONICAL",
|
||||
"note": "The malleated pair (r, n - s) of a valid signature. Every ECDSA library accepts it, which is exactly why the encoding rule and not the library has to reject it."
|
||||
},
|
||||
{
|
||||
"name": "DER encoded signature",
|
||||
"value": "MEUCIQCOINXhbLzVbCpDCXFWoXR4VOH2MQycPeaRRYHleoaamQIgEsNFp03QI7lR9cKkTHlBGJGSw07u3weWCUbwTdsK1LQ",
|
||||
"acceptedByALenientVerifier": true,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"note": "The same r and s in ASN.1. A verifier that hands whatever it decoded to its library accepts it; this surface has exactly one signature encoding."
|
||||
},
|
||||
{
|
||||
"name": "padded base64url signature",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN2wrUtA==",
|
||||
"acceptedByALenientVerifier": true,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"note": "The same 64 octets with = padding. Two spellings of one signature would make the signature useless as an artifact identity."
|
||||
},
|
||||
{
|
||||
"name": "truncated signature",
|
||||
"value": "jiDV4Wy81WwqQwlxVqF0eFTh9jEMnD3mkUWB5XqGmpkSw0WnTdAjuVH1wqRMeUEYkZLDTu7fB5YJRvBN",
|
||||
"acceptedByALenientVerifier": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"note": "Sixty octets. Left-padding it back to 64 would change r, so a verifier must reject rather than repair."
|
||||
},
|
||||
{
|
||||
"name": "zero r and zero s",
|
||||
"value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"acceptedByALenientVerifier": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"note": "Well formed in length and alphabet, and out of range in value."
|
||||
}
|
||||
],
|
||||
"verificationOrder": {
|
||||
"principle": "Parse as late as the verification key allows, and treat anything read before the signature verified as untrusted routing information rather than as a fact.",
|
||||
"enrollmentChallenge": {
|
||||
"note": "A challenge carries its own chain, so the CLI must read structure before it can verify anything. The pre-parse yields only trustChain, connectKeyId, and issuedAt, and none of them is believed: the chain has to close on a pinned root, and the challenge signature has to verify, before any other field is used.",
|
||||
"steps": [
|
||||
"check the signature encoding",
|
||||
"pre-parse the untrusted document for trustChain, connectKeyId, and issuedAt",
|
||||
"reject unless trustChain[0].issuerKeyId is a pinned root",
|
||||
"verify every trust link against its issuer and its validity at issuedAt",
|
||||
"reject unless connectKeyId is the subject of the last link",
|
||||
"verify the challenge signature over the received octets",
|
||||
"only now read protocolVersion, then formatVersion",
|
||||
"check the freshness window"
|
||||
]
|
||||
},
|
||||
"enrollmentResponse": {
|
||||
"note": "A response presents the device key it is enrolling, so Connect necessarily reads that key from the document. Proof of possession is what makes it safe: the presented key must be the key that signed the presenting document.",
|
||||
"steps": [
|
||||
"check the signature encoding",
|
||||
"reject unless deviceKeyId is the fingerprint of devicePublicKey and the signature verifies under devicePublicKey",
|
||||
"only now read protocolVersion, then formatVersion",
|
||||
"compare organization, then cluster, against the stored challenge",
|
||||
"compare challengeId, challengeNonce, and challengeProof against the stored challenge",
|
||||
"check the freshness window against producedAt, then against the receive time",
|
||||
"reject a challenge that was already consumed"
|
||||
]
|
||||
},
|
||||
"supportBundleManifest": {
|
||||
"note": "Connect already knows which device key is effective for a bundle, so nothing has to be parsed to find the verification key. Verification comes first and the manifest is not parsed at all until it has.",
|
||||
"steps": [
|
||||
"check the signature encoding",
|
||||
"resolve the detached signature keyId against the enrolled keys of the named bundle device",
|
||||
"verify the manifest signature over the raw manifest octets",
|
||||
"only now parse the manifest, and read protocolVersion, then formatVersion",
|
||||
"reject unless the manifest deviceKeyId is the key that signed it",
|
||||
"compare organization, cluster, and device against the authorised bundle",
|
||||
"check redactionVersion, then every entry type and classification",
|
||||
"check the freshness window",
|
||||
"reject a replayed nonce"
|
||||
]
|
||||
}
|
||||
},
|
||||
"trustAnchor": {
|
||||
"trustOnFirstUse": false,
|
||||
"rootLearnedFromArtifact": false,
|
||||
"rootShippedWithArtifact": false,
|
||||
"distribution": "The hosted RustFS enrollment root public key fingerprint is compiled into official RustFS builds. It is never read from a challenge, a bundle, a configuration file, or an operator prompt.",
|
||||
"pinnedRootKeyIds": [
|
||||
"df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f"
|
||||
],
|
||||
"pinnedRootPublicKeys": [
|
||||
{
|
||||
"keyId": "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
|
||||
"publicKey": "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s"
|
||||
}
|
||||
],
|
||||
"chainLinkCount": 2,
|
||||
"maxChainLinkCount": 2,
|
||||
"chainOrder": "index 0 is issued by a pinned root, index 1 is issued by the subject of index 0",
|
||||
"note": "Because no root is ever learned at runtime, an operator cannot be socially engineered into accepting an attacker root, and a stolen intermediate cannot mint its own root. The cost is that a root rollover requires redistributing the RustFS build, which is stated in rollover.root."
|
||||
},
|
||||
"keyHierarchy": [
|
||||
{
|
||||
"role": "enrollment-root",
|
||||
"holder": "RustFS",
|
||||
"algorithm": "ES256",
|
||||
"signs": [
|
||||
"intermediate trust links"
|
||||
],
|
||||
"maxValiditySeconds": null,
|
||||
"distribution": "pinned in official RustFS builds"
|
||||
},
|
||||
{
|
||||
"role": "intermediate",
|
||||
"holder": "RustFS Connect",
|
||||
"algorithm": "ES256",
|
||||
"signs": [
|
||||
"signing trust links"
|
||||
],
|
||||
"maxValiditySeconds": 31536000,
|
||||
"distribution": "carried inside every challenge as a signed trust link"
|
||||
},
|
||||
{
|
||||
"role": "signing",
|
||||
"holder": "RustFS Connect",
|
||||
"algorithm": "ES256",
|
||||
"signs": [
|
||||
"enrollment challenges"
|
||||
],
|
||||
"maxValiditySeconds": 2678400,
|
||||
"distribution": "carried inside every challenge as a signed trust link"
|
||||
},
|
||||
{
|
||||
"role": "device",
|
||||
"holder": "the air-gapped cluster device",
|
||||
"algorithm": "ES256",
|
||||
"signs": [
|
||||
"enrollment responses",
|
||||
"support bundle manifests"
|
||||
],
|
||||
"maxValiditySeconds": null,
|
||||
"distribution": "generated on the device, never transmitted; only the public point leaves it"
|
||||
}
|
||||
],
|
||||
"rollover": {
|
||||
"root": {
|
||||
"mechanism": "A new root is pinned by shipping a new official RustFS build. Both the outgoing and the incoming root stay pinned for the overlap window so a device running either build can still enroll.",
|
||||
"maxOverlapSeconds": 31536000,
|
||||
"learnedAtRuntime": false,
|
||||
"consequence": "A device that never takes a new build eventually cannot enroll. That is the accepted cost of refusing trust on first use."
|
||||
},
|
||||
"intermediate": {
|
||||
"mechanism": "Overlapping links. A challenge carries exactly the chain that validated it when it was issued, so a rolled intermediate does not invalidate challenges already in the field.",
|
||||
"maxValiditySeconds": 31536000,
|
||||
"validityEvaluatedAgainst": "the issuedAt of the challenge that carries the link, with no skew tolerance"
|
||||
},
|
||||
"signing": {
|
||||
"mechanism": "Overlapping links, rotated at least monthly.",
|
||||
"maxValiditySeconds": 2678400,
|
||||
"validityEvaluatedAgainst": "the issuedAt of the challenge that carries the link, with no skew tolerance"
|
||||
},
|
||||
"device": {
|
||||
"mechanism": "A device key is durable. Replacing it is a new enrollment: a fresh challenge, a fresh response, and a fresh device public key. There is no in-band device key rotation message.",
|
||||
"maxOverlapSeconds": 604800,
|
||||
"consequence": "The outgoing device key is revoked when the incoming one becomes effective, so a device never has more than one effective offline key."
|
||||
}
|
||||
},
|
||||
"revocation": {
|
||||
"device": {
|
||||
"effect": "immediate",
|
||||
"authority": "Connect, which holds the device key state and evaluates every artifact it receives",
|
||||
"retroactive": true,
|
||||
"note": "An artifact signed before revocation but received after it is still rejected. Revocation is not a validity window and past signatures are not grandfathered.",
|
||||
"reason": "DEVICE_KEY_REVOKED"
|
||||
},
|
||||
"signing": {
|
||||
"effect": "bounded by link validity",
|
||||
"authority": "RustFS Connect",
|
||||
"mechanism": "No CRL and no OCSP: an air-gapped device cannot fetch either, and a revocation list carried inside the artifact would simply be omitted by an attacker. Exposure is bounded by the 31 day signing link validity, and official RustFS builds additionally carry a denylist of revoked keyIds updated with each release."
|
||||
},
|
||||
"intermediate": {
|
||||
"effect": "bounded by link validity",
|
||||
"authority": "RustFS",
|
||||
"mechanism": "Same as signing, bounded by the 365 day intermediate link validity plus the build denylist."
|
||||
},
|
||||
"root": {
|
||||
"effect": "requires redistributing official RustFS builds",
|
||||
"authority": "RustFS",
|
||||
"mechanism": "There is nothing above the root to revoke it. This asymmetry is deliberate and is the reason the root signs nothing except intermediate links."
|
||||
},
|
||||
"asymmetry": "Connect can revoke a device key instantly because Connect holds that state and sees every artifact. A device cannot learn about a revoked Connect key promptly, because it has no network. Every offline-facing key therefore has a short validity instead of a revocation channel."
|
||||
},
|
||||
"clockSkew": {
|
||||
"toleranceSeconds": 300,
|
||||
"deviceClockAuthority": "advisory",
|
||||
"challengeWindow": "accepted while verifierNow is within [issuedAt - 300, expiresAt + 300]",
|
||||
"chainLinkWindow": "each link must satisfy notBefore <= challenge.issuedAt <= notAfter, evaluated with no tolerance because the issuer controls both values",
|
||||
"maxChallengeLifetimeSeconds": 604800,
|
||||
"maxManifestAgeSeconds": 2592000,
|
||||
"maxManifestFutureSkewSeconds": 300,
|
||||
"responseWindow": "producedAt must fall within [challenge.issuedAt - 300, challenge.expiresAt + 300]",
|
||||
"note": "ADR 0003 already treats client clocks as advisory for heartbeat freshness. An air-gapped device is worse: it may have no synchronised clock at all. Every window is therefore evaluated against the Connect clock for artifacts Connect receives, and against the issuer-supplied issuedAt for the chain a device validates locally."
|
||||
},
|
||||
"replay": {
|
||||
"challengeIdSingleUse": true,
|
||||
"consumedChallengeRetention": "until expiresAt + 300 seconds, so a late replay still meets a stored record rather than an empty table",
|
||||
"nonceLengthBytes": 32,
|
||||
"nonceEncoding": "base64url-unpadded",
|
||||
"noncePattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"manifestNonceUniqueness": "unique per organization, cluster, and device for at least maxManifestAgeSeconds",
|
||||
"signatureCanonicality": "Low-S normalisation makes the 64 octet signature a canonical identity for the artifact, so a malleated copy is not a second distinct artifact and cannot slip past deduplication.",
|
||||
"reasons": [
|
||||
"ENROLLMENT_REPLAYED",
|
||||
"BUNDLE_REPLAYED"
|
||||
]
|
||||
},
|
||||
"versioning": {
|
||||
"protocolVersionRule": "Identical to protocol/agent/v1/authentication.md: protocolVersion is v<major> matching ^v[1-9][0-9]{0,3}$, Connect supports major 1, and anything else fails closed with UNSUPPORTED_PROTOCOL and HTTP 400. Nothing is partially processed.",
|
||||
"supportedMajorVersions": [
|
||||
1
|
||||
],
|
||||
"protocolVersionPattern": "^v[1-9][0-9]{0,3}$",
|
||||
"formatVersionRule": "formatVersion is matched exactly against the closed list below. An unknown value fails closed with UNSUPPORTED_FORMAT and is never guessed at, prefix-matched, or downgraded.",
|
||||
"supportedFormatVersions": [
|
||||
"rustfs.connect.offline.trustLink/1",
|
||||
"rustfs.connect.offline.enrollmentChallenge/1",
|
||||
"rustfs.connect.offline.enrollmentResponse/1",
|
||||
"rustfs.connect.support.bundleManifest/1"
|
||||
],
|
||||
"additive": {
|
||||
"unknownOptionalFieldPolicy": "accept-and-discard",
|
||||
"unknownOptionalEntryFieldPolicy": "accept-and-discard",
|
||||
"echoedBack": false,
|
||||
"stored": false,
|
||||
"absentOptionalFieldPolicy": "take the documented default",
|
||||
"requiredFieldsMayBeAdded": false,
|
||||
"existingFieldsMayChangeTypeOrMeaning": false,
|
||||
"signatureImpact": "None. An unknown field is inside the signed octets and is therefore authentic; discarding it after verification cannot change the signature input, because the input is the received bytes and not a projection of the parsed document."
|
||||
},
|
||||
"closedEnumerations": {
|
||||
"note": "Enumerated values are closed and are NOT additive. Only optional fields are additive. An unrecognised enumerated value is a rejection, never a discard, because silently ignoring an unknown classification or entry type would let a producer widen what it collects.",
|
||||
"enumerations": [
|
||||
"signature.algorithm",
|
||||
"trustLink.role",
|
||||
"manifest.entries[].type",
|
||||
"manifest.entries[].classification"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
38e793226476bdb5f74c704c23ccc0e9ec09d51be3b51791e8b2cdfbf27a5c02 allowed-vectors.json
|
||||
014b06540e664f6e38f0174746a418069d2677a2a1a4ef96c17f16dec7e47886 rejection-vectors.json
|
||||
fdf1d8f4c7ed6f96026e86c7d89f56e5e08269c0b7a49a1e3c3880e5d023c600 ruleset.json
|
||||
5e349d5121037a09a9b1009b08feac9f5fb4b14cd6548419b88bd9f032929b55 secret-vectors.json
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "redaction",
|
||||
"fixture": "allowed-vectors",
|
||||
"description": "Ordinary collected documents. Every vector must survive with redactedCount 0: versions, counts, capacities, flags, and single-case digests are exactly what the L0 and L1 registry exists to collect, and over-redaction would make a support bundle useless.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "a complete heartbeat payload",
|
||||
"source": "heartbeat",
|
||||
"document": {
|
||||
"protocolVersion": 1,
|
||||
"agentVersion": "rustfs-agent/1.19.4",
|
||||
"capabilities": [
|
||||
"inventory",
|
||||
"events",
|
||||
"jobs"
|
||||
],
|
||||
"sequence": 8421,
|
||||
"clientTime": "2026-08-17T04:05:06Z",
|
||||
"coarseNodeSummary": {
|
||||
"total": 8,
|
||||
"healthy": 7,
|
||||
"degraded": 1
|
||||
}
|
||||
},
|
||||
"expectedCanonicalJson": "{\"agentVersion\":\"rustfs-agent/1.19.4\",\"capabilities\":[\"inventory\",\"events\",\"jobs\"],\"clientTime\":\"2026-08-17T04:05:06Z\",\"coarseNodeSummary\":{\"degraded\":1,\"healthy\":7,\"total\":8},\"protocolVersion\":1,\"sequence\":8421}"
|
||||
},
|
||||
{
|
||||
"name": "a complete inventory snapshot",
|
||||
"source": "inventory",
|
||||
"document": {
|
||||
"rustfsVersion": "1.19.4",
|
||||
"osVersion": "Ubuntu 22.04.5 LTS",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"coarseFlags": {
|
||||
"degraded": false,
|
||||
"readOnly": false,
|
||||
"rebalancing": true
|
||||
}
|
||||
},
|
||||
"expectedCanonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseFlags\":{\"degraded\":false,\"readOnly\":false,\"rebalancing\":true},\"driveCount\":96,\"nodeCount\":8,\"osVersion\":\"Ubuntu 22.04.5 LTS\",\"rustfsVersion\":\"1.19.4\"}"
|
||||
},
|
||||
{
|
||||
"name": "a complete offline diagnostic covering L0 and L1",
|
||||
"source": "offline-diagnostic",
|
||||
"document": {
|
||||
"rustfsVersion": "1.19.4",
|
||||
"nodeCount": 8,
|
||||
"driveCount": 96,
|
||||
"capacityUsedBytes": 412316860416,
|
||||
"capacityTotalBytes": 1099511627776,
|
||||
"coarseHealthFlags": {
|
||||
"degraded": false
|
||||
},
|
||||
"osSummary": "Ubuntu 22.04.5 LTS",
|
||||
"kernelSummary": "6.8.0-51-generic",
|
||||
"cpuSummary": {
|
||||
"architecture": "aarch64",
|
||||
"cores": 64
|
||||
},
|
||||
"memorySummary": {
|
||||
"totalBytes": 274877906944,
|
||||
"underPressure": false
|
||||
},
|
||||
"filesystemSummary": [
|
||||
"xfs",
|
||||
"ext4"
|
||||
],
|
||||
"networkSummary": {
|
||||
"interfaceCount": 4,
|
||||
"bondCount": 2
|
||||
}
|
||||
},
|
||||
"expectedCanonicalJson": "{\"capacityTotalBytes\":1099511627776,\"capacityUsedBytes\":412316860416,\"coarseHealthFlags\":{\"degraded\":false},\"cpuSummary\":{\"architecture\":\"aarch64\",\"cores\":64},\"driveCount\":96,\"filesystemSummary\":[\"xfs\",\"ext4\"],\"kernelSummary\":\"6.8.0-51-generic\",\"memorySummary\":{\"totalBytes\":274877906944,\"underPressure\":false},\"networkSummary\":{\"bondCount\":2,\"interfaceCount\":4},\"nodeCount\":8,\"osSummary\":\"Ubuntu 22.04.5 LTS\",\"rustfsVersion\":\"1.19.4\"}"
|
||||
},
|
||||
{
|
||||
"name": "single-case digests and long identifiers are not mistaken for key material",
|
||||
"source": "inventory",
|
||||
"document": {
|
||||
"rustfsVersion": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
"osVersion": "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
|
||||
},
|
||||
"expectedCanonicalJson": "{\"osVersion\":\"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855\",\"rustfsVersion\":\"da39a3ee5e6b4b0d3255bfef95601890afd80709\"}"
|
||||
},
|
||||
{
|
||||
"name": "extreme but ordinary capacity and count values survive unchanged",
|
||||
"source": "inventory",
|
||||
"document": {
|
||||
"nodeCount": 0,
|
||||
"driveCount": 1024,
|
||||
"capacityUsedBytes": 0,
|
||||
"capacityTotalBytes": 9223372036854775807,
|
||||
"coarseFlags": {
|
||||
"degraded": null,
|
||||
"utilisation": 0.9375
|
||||
}
|
||||
},
|
||||
"expectedCanonicalJson": "{\"capacityTotalBytes\":9223372036854775807,\"capacityUsedBytes\":0,\"coarseFlags\":{\"degraded\":null,\"utilisation\":0.9375},\"driveCount\":1024,\"nodeCount\":0}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "redaction",
|
||||
"fixture": "rejection-vectors",
|
||||
"description": "The input budget. A document the engine cannot scan inside its budget is refused whole rather than partially redacted, and every refusal message is built from a literal and an integer so no part of the input reaches the message or the stack trace.",
|
||||
"builders": {
|
||||
"literal": "Use document as it stands.",
|
||||
"nestedDepth": "{field: {nested: {... depth times ...: {leaf: 1}}}}.",
|
||||
"listNodes": "{field: [1 repeated count times]}.",
|
||||
"bulkStrings": "{field: {f0..f(entries-1): 'a' repeated valueBytes times}}.",
|
||||
"unrepresentable": "{field: NaN}, a float no JSON encoder can represent."
|
||||
},
|
||||
"vectors": [
|
||||
{
|
||||
"name": "a surface that is not a registered collection surface",
|
||||
"source": "support-bundle",
|
||||
"build": {
|
||||
"kind": "literal",
|
||||
"document": {
|
||||
"rustfsVersion": "1.19.4"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"refused": true,
|
||||
"message": "Redaction refused the document: it names no registered collection surface."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a document larger than the input budget",
|
||||
"source": "inventory",
|
||||
"build": {
|
||||
"kind": "bulkStrings",
|
||||
"field": "coarseFlags",
|
||||
"entries": 100,
|
||||
"valueBytes": 4000
|
||||
},
|
||||
"expected": {
|
||||
"refused": true,
|
||||
"message": "Redaction refused the document: its size in bytes exceeds the frozen budget of 262144."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a document nested past the depth budget",
|
||||
"source": "heartbeat",
|
||||
"build": {
|
||||
"kind": "nestedDepth",
|
||||
"field": "coarseNodeSummary",
|
||||
"depth": 8
|
||||
},
|
||||
"expected": {
|
||||
"refused": true,
|
||||
"message": "Redaction refused the document: its nesting depth exceeds the frozen budget of 8."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a document with more nodes than the node budget",
|
||||
"source": "heartbeat",
|
||||
"build": {
|
||||
"kind": "listNodes",
|
||||
"field": "capabilities",
|
||||
"count": 4096
|
||||
},
|
||||
"expected": {
|
||||
"refused": true,
|
||||
"message": "Redaction refused the document: its node count exceeds the frozen budget of 4096."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a value no JSON encoder can represent",
|
||||
"source": "inventory",
|
||||
"build": {
|
||||
"kind": "unrepresentable",
|
||||
"field": "capacityUsedBytes"
|
||||
},
|
||||
"expected": {
|
||||
"refused": true,
|
||||
"message": "Redaction refused the document: it is not representable as JSON."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "the deepest document the depth budget still accepts",
|
||||
"source": "heartbeat",
|
||||
"build": {
|
||||
"kind": "nestedDepth",
|
||||
"field": "coarseNodeSummary",
|
||||
"depth": 7
|
||||
},
|
||||
"expected": {
|
||||
"refused": false,
|
||||
"redactedCount": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "the largest node count the node budget still accepts",
|
||||
"source": "heartbeat",
|
||||
"build": {
|
||||
"kind": "listNodes",
|
||||
"field": "capabilities",
|
||||
"count": 4095
|
||||
},
|
||||
"expected": {
|
||||
"refused": false,
|
||||
"redactedCount": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "redaction",
|
||||
"fixture": "ruleset",
|
||||
"description": "The frozen deterministic redaction contract. A support bundle manifest stores redactionVersion and rulesetHash so a reader can prove which rules produced a redacted document, and a RustFS agent reproduces the same decisions from canonicalForm alone.",
|
||||
"redactionVersion": "rustfs.connect.redaction.v1",
|
||||
"redactionVersionFormat": "^rustfs\\.connect\\.redaction\\.v[1-9][0-9]*$",
|
||||
"redactionVersionNotes": [
|
||||
"An opaque stable identifier, not a semantic version: compare it for equality, never order it.",
|
||||
"A change to any line of canonicalForm changes rulesetHash and requires a new major."
|
||||
],
|
||||
"rulesetHash": "b37436d8e72515394a122d633865b1dc028d4ece349352a0a3a23f52ca4285f3",
|
||||
"rulesetHashAlgorithm": "sha256",
|
||||
"rulesetHashInput": "The canonicalForm lines below joined with U+000A and terminated with a final U+000A, encoded as UTF-8.",
|
||||
"collectionDecision": {
|
||||
"registry": "protocol/data-collection-fields.json",
|
||||
"rule": "Stage one is an allow-list. A field id absent from the registry is removed before its value is read, so an unknown, newly invented, or L2/L3 field can never be collected no matter what it contains. The value rules below are stage two and never grant collection."
|
||||
},
|
||||
"placeholder": {
|
||||
"token": "[REDACTED]",
|
||||
"rule": "One constant token for every redaction, carrying no rule name, no offset, no length, and no digest of the removed value. Redaction is not reversible and the result records no hash of anything it removed."
|
||||
},
|
||||
"output": {
|
||||
"canonicalJson": "Object keys are emitted in ascending byte order and an object that loses every entry is removed from its parent, so the same input and version always produce the same bytes.",
|
||||
"counts": [
|
||||
"droppedField: a field the allow-list refused, a key that is not a plain ASCII identifier, or an object left with no entries.",
|
||||
"redactedValue: a value replaced by the placeholder.",
|
||||
"redactedOversizeValue: a value replaced because it is longer than maxValueBytes and cannot be scanned within budget."
|
||||
]
|
||||
},
|
||||
"coverage": {
|
||||
"AWS_ACCESS_KEY_ID": "S3 and AWS access key ids.",
|
||||
"AWS_SECRET_ACCESS_KEY": "A standalone 40-character S3 secret access key. Mixed case plus a digit is required so a single-case hex digest of the same length is not redacted.",
|
||||
"BEARER_TOKEN": "HTTP bearer credentials, including a captured Authorization header.",
|
||||
"CREDENTIAL_ASSIGNMENT": "API keys, registration tokens, session tokens, passphrases, and KMS secrets written as an assignment in a connection string, environment dump, or configuration snippet.",
|
||||
"JWT": "JSON web tokens presented on their own.",
|
||||
"PASSWORD_ASSIGNMENT": "A password written as an assignment, including inside a DSN.",
|
||||
"PEM_PRIVATE_KEY": "Any PEM private key or private key block header, which covers device keys and KMS private material.",
|
||||
"SESSION_ID_ASSIGNMENT": "Session and CSRF identifiers written as an assignment.",
|
||||
"URL_CREDENTIALS": "Credentials in a URL or DSN authority. The whole value is replaced, so the host the credential belonged to is not published either."
|
||||
},
|
||||
"keyRuleNormalisation": "ASCII-lowercase the key and remove '_', '-', and '.', so secret_access_key, Secret-Access-Key, and secretAccessKey are the same key. A key that is not a plain ASCII identifier is dropped rather than normalised.",
|
||||
"canonicalForm": [
|
||||
"version\trustfs.connect.redaction.v1",
|
||||
"placeholder\t[REDACTED]",
|
||||
"budget\tmaxInputBytes\t262144",
|
||||
"budget\tmaxDepth\t8",
|
||||
"budget\tmaxNodes\t4096",
|
||||
"budget\tmaxValueBytes\t4096",
|
||||
"keyPattern\t/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/D",
|
||||
"field\theartbeat.agentVersion\tL0",
|
||||
"field\theartbeat.capabilities\tL0",
|
||||
"field\theartbeat.clientTime\tL0",
|
||||
"field\theartbeat.coarseNodeSummary\tL0",
|
||||
"field\theartbeat.protocolVersion\tL0",
|
||||
"field\theartbeat.sequence\tL0",
|
||||
"field\tinventory.capacityTotalBytes\tL0",
|
||||
"field\tinventory.capacityUsedBytes\tL0",
|
||||
"field\tinventory.coarseFlags\tL0",
|
||||
"field\tinventory.driveCount\tL0",
|
||||
"field\tinventory.nodeCount\tL0",
|
||||
"field\tinventory.osVersion\tL0",
|
||||
"field\tinventory.rustfsVersion\tL0",
|
||||
"field\toffline.capacityTotalBytes\tL0",
|
||||
"field\toffline.capacityUsedBytes\tL0",
|
||||
"field\toffline.coarseHealthFlags\tL0",
|
||||
"field\toffline.cpuSummary\tL1",
|
||||
"field\toffline.driveCount\tL0",
|
||||
"field\toffline.filesystemSummary\tL1",
|
||||
"field\toffline.kernelSummary\tL1",
|
||||
"field\toffline.memorySummary\tL1",
|
||||
"field\toffline.networkSummary\tL1",
|
||||
"field\toffline.nodeCount\tL0",
|
||||
"field\toffline.osSummary\tL1",
|
||||
"field\toffline.rustfsVersion\tL0",
|
||||
"keyRule\taccesskey",
|
||||
"keyRule\taccesskeyid",
|
||||
"keyRule\tapikey",
|
||||
"keyRule\tapitoken",
|
||||
"keyRule\tauthorization",
|
||||
"keyRule\tbearertoken",
|
||||
"keyRule\tcookie",
|
||||
"keyRule\tcredential",
|
||||
"keyRule\tcredentials",
|
||||
"keyRule\tcsrftoken",
|
||||
"keyRule\tkmskey",
|
||||
"keyRule\tkmskeyid",
|
||||
"keyRule\tkmsmasterkey",
|
||||
"keyRule\tkmssecret",
|
||||
"keyRule\tpassphrase",
|
||||
"keyRule\tpasswd",
|
||||
"keyRule\tpassword",
|
||||
"keyRule\tprivatekey",
|
||||
"keyRule\tpwd",
|
||||
"keyRule\trefreshtoken",
|
||||
"keyRule\tregistrationtoken",
|
||||
"keyRule\tsecret",
|
||||
"keyRule\tsecretaccesskey",
|
||||
"keyRule\tsecretkey",
|
||||
"keyRule\tsessioncookie",
|
||||
"keyRule\tsessionid",
|
||||
"keyRule\tsessiontoken",
|
||||
"keyRule\tsigningkey",
|
||||
"keyRule\ttoken",
|
||||
"keyRule\txsrftoken",
|
||||
"valueRule\tAWS_ACCESS_KEY_ID\t/\\b(?:A3T[A-Z0-9]{2}|ABIA|ACCA|AKIA|ASIA)[A-Z0-9]{16}\\b/",
|
||||
"valueRule\tAWS_SECRET_ACCESS_KEY\t/(?<![A-Za-z0-9+\\/])(?=[A-Za-z0-9+\\/]{0,39}[a-z])(?=[A-Za-z0-9+\\/]{0,39}[A-Z])(?=[A-Za-z0-9+\\/]{0,39}[0-9])[A-Za-z0-9+\\/]{40}(?![A-Za-z0-9+\\/=])/",
|
||||
"valueRule\tBEARER_TOKEN\t/(?i)\\bbearer\\s{1,8}[A-Za-z0-9\\-._~+\\/]{8,4096}={0,2}/",
|
||||
"valueRule\tCREDENTIAL_ASSIGNMENT\t/(?i)\\b[a-z0-9_.-]{0,24}(?:access[_.-]?key(?:[_.-]?id)?|api[_.-]?key|credentials?|passphrase|secret(?:[_.-]?key)?|token)\\b\\s{0,8}[:=]\\s{0,8}[\"\\x27]?[A-Za-z0-9\\-._~+\\/=]{8,4096}/",
|
||||
"valueRule\tJWT\t/\\beyJ[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{4,4096}\\.[A-Za-z0-9_-]{4,4096}/",
|
||||
"valueRule\tPASSWORD_ASSIGNMENT\t/(?i)\\b(?:passwd|password|pwd)\\b\\s{0,8}[:=]\\s{0,8}\\S/",
|
||||
"valueRule\tPEM_PRIVATE_KEY\t/-----BEGIN [A-Z0-9 ]{0,32}PRIVATE KEY(?: BLOCK)?-----/",
|
||||
"valueRule\tSESSION_ID_ASSIGNMENT\t/(?i)\\b(?:csrf[_.-]?token|jsessionid|phpsessid|sess|session|sid|xsrf[_.-]?token)(?:[_.-]?id)?\\b\\s{0,8}[:=]\\s{0,8}[\"\\x27]?[A-Za-z0-9%\\-._~+\\/]{12,4096}/",
|
||||
"valueRule\tURL_CREDENTIALS\t/\\b[a-zA-Z][a-zA-Z0-9+.\\-]{0,31}:\\/\\/[^\\s\\/@:]{1,256}(?::[^\\s\\/@]{0,256})?@/"
|
||||
]
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "redaction",
|
||||
"fixture": "secret-vectors",
|
||||
"description": "Realistic secret material presented to the engine. Every literal in secretLiterals must be absent from the redacted output, from any log, and from any exception. The example values are synthetic: the PEM body is not a usable key and the AWS values are the documented example credentials.",
|
||||
"expectations": [
|
||||
"expectedCanonicalJson is the exact output bytes for this document at rustfs.connect.redaction.v1.",
|
||||
"valueRule names the rule that fires for ruleSubject; a null valueRule means the removal comes from the allow-list, a key rule, or the ASCII key rule instead."
|
||||
],
|
||||
"vectors": [
|
||||
{
|
||||
"name": "an S3 access key id smuggled into the version field",
|
||||
"source": "inventory",
|
||||
"valueRule": "AWS_ACCESS_KEY_ID",
|
||||
"ruleSubject": "AKIAIOSFODNN7EXAMPLE",
|
||||
"document": {
|
||||
"rustfsVersion": "AKIAIOSFODNN7EXAMPLE",
|
||||
"nodeCount": 4
|
||||
},
|
||||
"secretLiterals": [
|
||||
"AKIAIOSFODNN7EXAMPLE"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"nodeCount\":4,\"rustfsVersion\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "an S3 secret access key standing alone in an offline OS summary",
|
||||
"source": "offline-diagnostic",
|
||||
"valueRule": "AWS_SECRET_ACCESS_KEY",
|
||||
"ruleSubject": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"document": {
|
||||
"osSummary": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"rustfsVersion": "1.19.4"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"osSummary\":\"[REDACTED]\",\"rustfsVersion\":\"1.19.4\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a captured bearer authorization header in a kernel summary",
|
||||
"source": "offline-diagnostic",
|
||||
"valueRule": "BEARER_TOKEN",
|
||||
"ruleSubject": "Linux 6.8.0; upstream call used Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
"document": {
|
||||
"kernelSummary": "Linux 6.8.0; upstream call used Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"kernelSummary\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a JSON web token inside a heartbeat capability list",
|
||||
"source": "heartbeat",
|
||||
"valueRule": "JWT",
|
||||
"ruleSubject": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
"document": {
|
||||
"capabilities": [
|
||||
"inventory",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
"jobs"
|
||||
],
|
||||
"sequence": 12
|
||||
},
|
||||
"secretLiterals": [
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJydXN0ZnMtZGV2aWNlIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"capabilities\":[\"inventory\",\"[REDACTED]\",\"jobs\"],\"sequence\":12}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a connection string carrying an embedded password",
|
||||
"source": "offline-diagnostic",
|
||||
"valueRule": "URL_CREDENTIALS",
|
||||
"ruleSubject": "postgresql://rustfs:s3cr3t-p4ss@db.internal:5432/connect",
|
||||
"document": {
|
||||
"filesystemSummary": "postgresql://rustfs:s3cr3t-p4ss@db.internal:5432/connect"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"s3cr3t-p4ss",
|
||||
"db.internal"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"filesystemSummary\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a PEM private key nested inside a coarse node summary",
|
||||
"source": "heartbeat",
|
||||
"valueRule": "PEM_PRIVATE_KEY",
|
||||
"ruleSubject": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxEXAMPLEKEYBODYnotarealkey0000000000000000000000\nEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE=\n-----END RSA PRIVATE KEY-----",
|
||||
"document": {
|
||||
"coarseNodeSummary": {
|
||||
"healthy": 4,
|
||||
"note": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxEXAMPLEKEYBODYnotarealkey0000000000000000000000\nEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE=\n-----END RSA PRIVATE KEY-----"
|
||||
}
|
||||
},
|
||||
"secretLiterals": [
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAxEXAMPLEKEYBODYnotarealkey0000000000000000000000\nEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE=\n-----END RSA PRIVATE KEY-----",
|
||||
"EXAMPLEKEYBODY"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"coarseNodeSummary\":{\"healthy\":4,\"note\":\"[REDACTED]\"}}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a DSN password assignment in a CPU summary",
|
||||
"source": "offline-diagnostic",
|
||||
"valueRule": "PASSWORD_ASSIGNMENT",
|
||||
"ruleSubject": "Server=db;User Id=rustfs;Password=hunter2xyz;",
|
||||
"document": {
|
||||
"cpuSummary": "Server=db;User Id=rustfs;Password=hunter2xyz;"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"hunter2xyz"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"cpuSummary\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a session identifier assignment appended to an OS version",
|
||||
"source": "inventory",
|
||||
"valueRule": "SESSION_ID_ASSIGNMENT",
|
||||
"ruleSubject": "Ubuntu 22.04.5 LTS; PHPSESSID=9f8a7b6c5d4e3f2a1b0c",
|
||||
"document": {
|
||||
"osVersion": "Ubuntu 22.04.5 LTS; PHPSESSID=9f8a7b6c5d4e3f2a1b0c"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"9f8a7b6c5d4e3f2a1b0c"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"osVersion\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a registration token assignment in an agent version string",
|
||||
"source": "heartbeat",
|
||||
"valueRule": "CREDENTIAL_ASSIGNMENT",
|
||||
"ruleSubject": "rustfs-agent/1.19.4 registration_token=rft-9f8a7b6c5d4e3f2a1b0c",
|
||||
"document": {
|
||||
"agentVersion": "rustfs-agent/1.19.4 registration_token=rft-9f8a7b6c5d4e3f2a1b0c"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"rft-9f8a7b6c5d4e3f2a1b0c"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"agentVersion\":\"[REDACTED]\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "KMS key material recognised by its key name alone",
|
||||
"source": "inventory",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"coarseFlags": {
|
||||
"degraded": false,
|
||||
"kms_master_key": "AQIDAHhEXAMPLEkmsDataKeyCiphertext"
|
||||
}
|
||||
},
|
||||
"secretLiterals": [
|
||||
"AQIDAHhEXAMPLEkmsDataKeyCiphertext"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"coarseFlags\":{\"degraded\":false,\"kms_master_key\":\"[REDACTED]\"}}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a secret access key under a separated and mixed-case key name",
|
||||
"source": "inventory",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"coarseFlags": {
|
||||
"Secret-Access-Key": "zz9EXAMPLEkey",
|
||||
"driveCount": 24
|
||||
}
|
||||
},
|
||||
"secretLiterals": [
|
||||
"zz9EXAMPLEkey"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"coarseFlags\":{\"Secret-Access-Key\":\"[REDACTED]\",\"driveCount\":24}}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 0,
|
||||
"redactedValue": 1,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "a brand new secret-bearing field that matches no value rule at all",
|
||||
"source": "inventory",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"rustfsVersion": "1.19.4",
|
||||
"vendorSupportCredential": "ordinary-looking-handle-42",
|
||||
"objectStoreRootPassphrase": "correct horse battery staple"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"ordinary-looking-handle-42",
|
||||
"correct horse battery staple",
|
||||
"vendorSupportCredential",
|
||||
"objectStoreRootPassphrase"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"rustfsVersion\":\"1.19.4\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 2,
|
||||
"redactedValue": 0,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 2
|
||||
},
|
||||
{
|
||||
"name": "deferred L2 and L3 collection is refused by the allow-list",
|
||||
"source": "offline-diagnostic",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"rustfsVersion": "1.19.4",
|
||||
"configuration": {
|
||||
"endpoint": "https://s3.example.internal"
|
||||
},
|
||||
"logs": [
|
||||
"level=error msg=\"auth failed for user rustfs\""
|
||||
],
|
||||
"traces": "span 8f2c1a",
|
||||
"profiles": "cpu.pprof"
|
||||
},
|
||||
"secretLiterals": [
|
||||
"s3.example.internal",
|
||||
"auth failed for user rustfs",
|
||||
"span 8f2c1a",
|
||||
"cpu.pprof"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"rustfsVersion\":\"1.19.4\"}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 4,
|
||||
"redactedValue": 0,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 4
|
||||
},
|
||||
{
|
||||
"name": "a confusable non-ASCII key inside an allow-listed object is dropped",
|
||||
"source": "inventory",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"coarseFlags": {
|
||||
"secretAccessKey": "AKIAIOSFODNN7EXAMPLE",
|
||||
"degraded": true
|
||||
}
|
||||
},
|
||||
"secretLiterals": [
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"secretAccessKey"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"coarseFlags\":{\"degraded\":true}}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 1,
|
||||
"redactedValue": 0,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 1
|
||||
},
|
||||
{
|
||||
"name": "an allow-listed object that loses every entry is removed entirely",
|
||||
"source": "heartbeat",
|
||||
"valueRule": null,
|
||||
"ruleSubject": null,
|
||||
"document": {
|
||||
"coarseNodeSummary": {
|
||||
"perNode": {
|
||||
"hostname": "node-01.rustfs.internal"
|
||||
}
|
||||
},
|
||||
"sequence": 7
|
||||
},
|
||||
"secretLiterals": [
|
||||
"node-01.rustfs.internal",
|
||||
"perNode",
|
||||
"coarseNodeSummary"
|
||||
],
|
||||
"expectedCanonicalJson": "{\"sequence\":7}",
|
||||
"expectedCounts": {
|
||||
"droppedField": 3,
|
||||
"redactedValue": 0,
|
||||
"redactedOversizeValue": 0
|
||||
},
|
||||
"expectedRedactedCount": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
812b0ba479a4c8d8eb9776e7bcb8d4c4d929bb83f372c03bec064472bca6155a accept-vectors.json
|
||||
eb197077a2db61ae3114fa52cdeb32715f8060f6ce5f2b9bae7fe7e7f78b4981 error-codes.json
|
||||
3940cc260b21a8655e5ebbdbeccd06a273d2299ce783eef92b22cacdaebc80e1 reject-vectors.json
|
||||
58a7126cef796dd0631b2de8d31528267e6281566646a5662dd3ad555a530008 transcript.json
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "registration",
|
||||
"fixture": "accept-vectors",
|
||||
"description": "Registration proof-of-possession exchanges that verify. Each vector carries the token row Connect rebuilds the transcript from, the request body the device sent, and the exact transcript octets that rebuild produces. evaluatedAt is the Connect clock the vector is evaluated at; the bytes are frozen, so a window is a property of the evaluation and not of them. No vector carries a registration token secret or any private key.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "device proof over the frozen transcript",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The reference exchange. Every reject vector below is this one with exactly one input substituted.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": true,
|
||||
"reason": null,
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "the same certificate request under a second requestId carries its own proof",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "requestId is inside the transcript, so a device that starts a second attempt signs again. It cannot move a proof it already produced onto a new idempotency key.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "7c4d2e10-9f83-4a5b-b6c7-d8e9f0a1b2c3",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "f0YRwKo8HTMuvCBlh0v3OjqCVplGPVfXqfTgxO4slepy1XdL6bYROjXYwKfn7ZZtkXH5PdOfrC37qITLT4cirQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:7c4d2e10-9f83-4a5b-b6c7-d8e9f0a1b2c3\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "561bda4be56f6334379c9832b73c74eca7d01a7cef95ef6ab0c5c13499bbb90c",
|
||||
"expected": {
|
||||
"accepted": true,
|
||||
"reason": null,
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a second device registering against a second token of the same cluster",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Nothing in the transcript is global: a second key, a second token, and a second nonce produce an unrelated transcript under the same cluster.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "5c7e9a0b1d2f3041526374859607b8c9dae0f1023456789abcdef0123456789a",
|
||||
"expiresUnix": 1787229000,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "5e1b7a36-2c4d-48ef-90ab-1c2d3e4f5061",
|
||||
"registrationTokenUid": "0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATSvBqHsbWVSUjYckACjTRW7wm+lGHKffqZazn/RBmkdkcSeuERS/160K4N2EdiwQWNU/g0LD+9kzrhI6M7TXrgoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiEAxZXtloz/p6atQT/sqMxjlcHHN7sq/2f2YeND2oGJCYUCIBH+jf269LueLR4dAZIO7AygdPBf3NxW1PgBu3U4UElH",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "gPExu0WlRxfLeDC388xb15VL8yuMEGnbdkz-CapNBscx4JX8bP0JVbkumMQyddX4BpRAldeeQZ_GJjuxrUVn8w"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:5e1b7a36-2c4d-48ef-90ab-1c2d3e4f5061\n64:5c7e9a0b1d2f3041526374859607b8c9dae0f1023456789abcdef0123456789a\n10:1787229000\n43:srwagTJq3yDJm_u_cO-u9KLQWyLeJAouFWiFuNLNSMQ\n",
|
||||
"serverTranscriptSha256": "6873d380ebf6634c3e36b402ab74f3c46471d7f725b747dda1843d14a3a7b655",
|
||||
"expected": {
|
||||
"accepted": true,
|
||||
"reason": null,
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "registration",
|
||||
"fixture": "error-codes",
|
||||
"description": "Frozen ErrorInfo reasons for the registration token exchange and its proof of possession. Clients branch on status and reason, never on message. Two of the reasons a rejected exchange can carry are defined elsewhere and are cited here rather than restated, so this surface can never come to mean something different by them.",
|
||||
"domain": "rustfs.connect",
|
||||
"detailType": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
"disclosureRules": [
|
||||
"A rejection never says which of the seven transcript bindings disagreed. Every binding failure is REGISTRATION_PROOF_INVALID.",
|
||||
"A rejection never says whether a registration token uid exists, whether the presented secret was right, whether the token was already spent, expired, or revoked, or whether another request holds its reservation. Every one of those is REGISTRATION_TOKEN_UNUSABLE.",
|
||||
"A rejection never contains a registration token secret, a challenge nonce, certificate request octets, or key material.",
|
||||
"A rejection never reveals the organization or cluster a token belongs to."
|
||||
],
|
||||
"reasons": [
|
||||
{
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The requested protocol major version is missing, malformed, or not supported.",
|
||||
"definedBy": "protocol/agent/v1/authentication.md",
|
||||
"note": "Cited, not redefined. The exchange applies the rule already frozen for every agent operation: nothing is partially processed, stored, or echoed."
|
||||
},
|
||||
{
|
||||
"reason": "UNSUPPORTED_ALGORITHM",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "proof.algorithm is a value other than ES256.",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "The enumeration is closed. An unrecognised algorithm is refused rather than discarded, because a discarded algorithm would leave the proof to be interpreted by whatever its bytes happen to look like."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "proof.value is not 86 unpadded base64url characters decoding to 64 octets whose r and s both lie in [1, n).",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "DER, padded base64url, the standard base64 alphabet, a truncated value, and an out-of-range value all land here. The encoding is checked before any key is loaded."
|
||||
},
|
||||
{
|
||||
"reason": "SIGNATURE_NOT_CANONICAL",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "proof.value is well formed but its s exceeds half the group order.",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "Such a proof verifies mathematically. Only the encoding rule refuses it, which is what makes the 64 octet value a canonical identity for one exchange rather than one of two equally valid spellings."
|
||||
},
|
||||
{
|
||||
"reason": "CERTIFICATE_REQUEST_MALFORMED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "certificateRequest is not exactly one well-formed PKCS#10 DER structure, or its ES256 self-signature does not verify under the key it presents.",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "Trailing octets after the outer SEQUENCE are malformed, not ignored: two readers that disagree about where a certificate request ends would disagree about its digest."
|
||||
},
|
||||
{
|
||||
"reason": "DEVICE_KEY_UNSUPPORTED",
|
||||
"httpStatus": 400,
|
||||
"status": "INVALID_ARGUMENT",
|
||||
"meaning": "The SubjectPublicKeyInfo of the certificate request is not an ECDSA key on NIST P-256.",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "Separate from CERTIFICATE_REQUEST_MALFORMED because the request is structurally fine and the refusal is a policy one: ADR 0008 fixes the device key and this surface may not widen it."
|
||||
},
|
||||
{
|
||||
"reason": "REGISTRATION_TOKEN_UNUSABLE",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "No usable registration token matches the presented uid and secret at this instant.",
|
||||
"definedBy": "App\\Modules\\Clusters\\Application\\Contracts\\RegistrationTokenPort",
|
||||
"note": "Cited, not redefined. The port already answers conditionally for every reason a caller does not own an exchange, and this single reason is the whole of what the surface may say about why."
|
||||
},
|
||||
{
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"httpStatus": 401,
|
||||
"status": "UNAUTHENTICATED",
|
||||
"meaning": "The proof does not verify over the transcript Connect rebuilt, under the key inside the presented certificate request.",
|
||||
"definedBy": "protocol/agent/v1/registration-proof.md",
|
||||
"note": "One reason for every substitution: another token, another cluster, another organization, another requestId, another nonce, a changed expiry, a substituted certificate request, a variant transcript encoding, or a proof by another key. Naming which one failed would turn the exchange into an oracle for the contents of a token row."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,814 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "registration",
|
||||
"fixture": "reject-vectors",
|
||||
"description": "Every rejection the registration transcript exists to produce. Except where a vector says otherwise, each one is the first accept vector with exactly one input substituted, so the field that changed is the field that caused the rejection. stage names the frozen check that refuses it. verifiesMathematically is what a lenient verifier gets: one that accepts either base64 alphabet with or without padding, takes DER or fixed-width r||s, and asks its ECDSA library whether the numbers work out over serverTranscript under the key inside the presented certificate request. A vector that is rejected while verifiesMathematically is true is a vector the library alone would have accepted, and is the reason the rule and not the library decides. null means there was no usable key to ask with.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "protocol major version the exchange does not support",
|
||||
"stage": "protocolVersion",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The bytes are the accepted exchange untouched. protocol/agent/v1/authentication.md already owns this rejection and it happens before anything here is parsed, which is why the proof still verifies.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v2",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "malleated high-S proof over the accepted transcript",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The pair (r, n - s) of the accepted proof. Every ECDSA library verifies it, so the encoding rule and not the library has to refuse it.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-vCuYrhTSEzwQbRldM3mXMgun6ghSOPBl1P9zBV6GPKnA"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_NOT_CANONICAL",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DER encoded proof",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The same r and s in ASN.1. This surface has exactly one signature encoding.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "MEUCIQCJQtZ-rcHMlDaYioWRmiY8cBdqFaNtQ_URM7woEhGv6wIgPUZ1HbLezD_5LmosyGaM3wJoWiiDiJgno8KabRP_WrU"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "padded base64url proof",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The same 64 octets with = padding. Two spellings of one proof would make the proof useless as an exchange identity.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ=="
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "standard base64 alphabet proof",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Unpadded, right length, wrong alphabet. Whether it is even distinguishable from base64url depends on the bytes, which is exactly why the alphabet is fixed rather than sniffed.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr+s9RnUdst7MP/kuaizIZozfAmhaKIOImCejwpptE/9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "truncated proof",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Sixty octets. Left-padding it back to 64 would change r, so a verifier rejects rather than repairs.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwppt"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof whose r and s are both zero",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Well formed in length and alphabet, out of range in value.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "SIGNATURE_MALFORMED",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof declaring ES384",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The value is the accepted proof. algorithm is a closed enumeration, so an unrecognised value is refused rather than ignored in favour of what the bytes look like.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES384",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_ALGORITHM",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof declaring none",
|
||||
"stage": "encoding",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The JWS \"none\" downgrade, refused before any key is loaded.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "none",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "UNSUPPORTED_ALGORITHM",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "certificate request carrying a P-384 key",
|
||||
"stage": "certificateRequest",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "A structurally valid, correctly self-signed PKCS#10 whose key is on the wrong curve. ADR 0008 fixes P-256 for the device identity and this surface may not widen it.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBhDCCAQoCAQAwLzEtMCsGA1UEAwwkMDE5OGY0YjAtOGIwMC03ZDgwLTk0OTEtOWZhMGIxYzJkM2U3MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEK8Kel2qfLdRNZi5vrCirhsFMQXe89k2TE4gwd4oQ0EOdGEQNZTbesxTdxDmNJ47urHFS9+uw4WwGfgV5yn8jtSX2ciwSpOVW7KlZryHkvsDG5yjk81iUuBasjQlirXxsoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNoADBlAjEA8xjrA4XsOUjpmohfjygdkYBo6pqFQGDYGc9h3RBGRwgGiDkECS1B/WYEAZ43GOheAjB4YnNDjfRtDyRGG2xOnXvFO+KmNWeA97lO7mr3FiHiTsIE2B3+8OnXQ3mGl9X7gak=",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:buDezBlPJr91rBBKJQ7QiTC7uHeOhqO0ye0u03jPD_Q\n",
|
||||
"serverTranscriptSha256": "a7af29202bf52b96b4f2dd208a670fdd411e801c8c15ab89aac88ad54d0b6a6a",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "DEVICE_KEY_UNSUPPORTED",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "certificate request whose self-signature was altered",
|
||||
"stage": "certificateRequest",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "One octet of the PKCS#10 signature flipped. The structure parses and the key is intact, so only actually checking the self-signature catches it.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXN",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:9hgfdYlj31WfxakroWSOLY5A2aW3VmsCj2DQPzo9GC4\n",
|
||||
"serverTranscriptSha256": "212d742af9dc4846d2b29513e740a111d879da232034c767d7af73f9b15d6312",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "CERTIFICATE_REQUEST_MALFORMED",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "certificate request that is not DER at all",
|
||||
"stage": "certificateRequest",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Arbitrary octets. They still have a SHA-256 and therefore still produce a transcript, which is why the certificate request is checked before the proof rather than after it.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "dGhpcyBpcyBub3QgYSBQS0NTIzEwIGNlcnRpZmljYXRlIHJlcXVlc3Q=",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:vrub5r3pd54hnKvH9kBvkfWy2OR6nT5GhNSVfG2D6s4\n",
|
||||
"serverTranscriptSha256": "9e9abfa9bdbca21c754065f1a9a3a129305cba692df4beaaaf3f996677e569a1",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "CERTIFICATE_REQUEST_MALFORMED",
|
||||
"verifiesMathematically": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "replay of an accepted exchange against the token it already spent",
|
||||
"stage": "registrationToken",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Byte-identical to the accepted exchange, replayed after the token was consumed. The proof still verifies and must: replay is a state decision the token row owns, not a signature failure.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "CONSUMED"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_TOKEN_UNUSABLE",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted exchange presented after its token expired",
|
||||
"stage": "registrationToken",
|
||||
"evaluatedAt": "2026-08-20T12:20:00Z",
|
||||
"note": "The same octets five minutes after expiresUnix. Freshness is evaluated against the Connect clock and the stored row; the proof itself never goes stale, which is why an expiry lives in the transcript and a window does not.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_TOKEN_UNUSABLE",
|
||||
"verifiesMathematically": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof produced by a key other than the one in the certificate request",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "A real ES256 signature over the correct transcript, by the wrong key. The verifying key is only ever the one inside the presented certificate request.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "v5kin4DflqIdg9brBFQv1iqzz9Tk-gt8MBWjcm4XYZMNuAzeaOMLxW5hAPpYUibUn2zE0RxB-hmCvqllQ0GCXQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof presented against a different token of the same cluster",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The token uid, the challenge nonce, and the expiry all come from the other row, so the rebuilt transcript differs in three fields at once.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "5c7e9a0b1d2f3041526374859607b8c9dae0f1023456789abcdef0123456789a",
|
||||
"expiresUnix": 1787229000,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-7a00-7c70-8381-8e9fa0b1c2d6\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:5c7e9a0b1d2f3041526374859607b8c9dae0f1023456789abcdef0123456789a\n10:1787229000\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "804816033a4525444ef4277ffe5b1f547a74b54b3e488377108e5ee587dab8fa",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof presented against another cluster of the same organization",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Only clusterUid differs. A device enrolled by an operator of one cluster can never land in another, even inside the tenant it belongs to.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2ad4639edb25e97e7f056a702afed2ac1c4c5c13f8361bf86e63d5d37273ac55",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof presented against a cluster of another organization",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The cross-tenant case. organizationUid is in the transcript so that a proof is unusable outside the tenant that issued its token even if every other value were somehow reproduced.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-4d00-7f40-9051-5b6c7d8e9fa3",
|
||||
"clusterUid": "0198f4b0-5e00-7a50-8161-6c7d8e9fa0b4",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-4d00-7f40-9051-5b6c7d8e9fa3\n36:0198f4b0-5e00-7a50-8161-6c7d8e9fa0b4\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "cb050ac257fdcd258eb24a9395c87ffc61113b4c2b3bcbf5d9ed2dccac2968a0",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof moved onto a different requestId",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "A captured body resubmitted under a fresh idempotency key. Binding requestId is what keeps a captured exchange replayable only as itself.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "7c4d2e10-9f83-4a5b-b6c7-d8e9f0a1b2c3",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:7c4d2e10-9f83-4a5b-b6c7-d8e9f0a1b2c3\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "561bda4be56f6334379c9832b73c74eca7d01a7cef95ef6ab0c5c13499bbb90c",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof against a row whose challenge nonce differs",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "Everything a console reader can see is unchanged; only the value delivered once with the secret differs. A proof cannot be precomputed from public token metadata.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "ffeeddccbbaa99887766554433221100ffeeddccbbaa998877665544332211ff",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:ffeeddccbbaa99887766554433221100ffeeddccbbaa998877665544332211ff\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "d2d678baf0f396b5d9bef9acea01498020ae45fd479a41b1e49d8ec1ed2ae117",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof against a row whose expiry was extended",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "One hour added to expiresUnix. A window that is widened after the fact invalidates every proof produced for the old one instead of quietly resurrecting them.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787231700,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787231700\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "bf31d8f89414ba4fe9c4984844c33da79114ef77833f6401bc02485dca779ef1",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accepted proof presented with a substituted certificate request",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "A perfectly valid, correctly self-signed certificate request for an attacker key. This is the vector that a verifier checking only the PKCS#10 self-signature would accept, and it would issue a device certificate for the attacker.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRzCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARTzytYBF/PPAE2u2p2FYzUiVVwCnvUM/Mr27XLI9NIIP4vQlQ85RY4wSD70xXbUvOXqMwsMNBhp0edNfJ98Dw7oFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNJADBGAiEA/DI2AaTqO+JSj6xWLHHsyULX3s2P5cB3gFzrQ3gA1E4CIQDS5g7SkVicYc7IvJwszDjs7XluJTGB8bN0+MComIntAg==",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:0quILEdbHQp-KF4fBFc7vrrE5-M4fErrGoyT3vPnxIM\n",
|
||||
"serverTranscriptSha256": "673b6ccbb55c9e0ce7e645955ec0ab8a9d97c29da3b776c57a6047a557c55624",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof over a transcript whose expiry carries a leading zero",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "A real signature over a transcript whose sixth field reads 11:01787228100 instead of 10:1787228100. The length prefix moves with the value, so a variant spelling of one integer is a different transcript and not an equivalent one. A verifier normalising the number instead of refusing the proof would give one exchange two valid transcripts.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "E2Re4CCZK5AXtug_Q2So2W38x5QdNBT-sj3qMMZKpDRjbtj6gsJzjtvHcnJJ9qCwFn3vOCoHIfoecPj3HEJc_g"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof over a transcript separated by 0x00 instead of 0x0a",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The separator ADR 0009 uses for a signed document, applied to a transcript that fixes 0x0a. Two separator conventions must not produce one accepted proof.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "0raUrZjLCZqadBmJZHmV9IEd4Y0E0FwIYl_ODzN5OtYgPQtdM3GALzeUddkqUSkbp1fkJ4EFYVqtVAQyfiPS9Q"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof over a transcript whose domain is lowercased",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The domain is compared as octets. Case folding it would let a producer pick either spelling and give one exchange two identities.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "aZPdYJtL-SoyTpkUCFKbLNtYZLNRm57uoA2Q75-l8VgvwE84TzC3yM-mkQXqr6ffflJ-vz2zwF4gLUQBSpe7Zw"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "proof over a JSON serialisation of the same field values",
|
||||
"stage": "proof",
|
||||
"evaluatedAt": "2026-08-20T12:00:00Z",
|
||||
"note": "The transcript is not a document and is never JSON. A JSON signature input would make key order, escaping, and number formatting part of the contract.",
|
||||
"tokenRecord": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"state": "ACTIVE"
|
||||
},
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"certificateRequest": "MIIBRjCB7QIBADAvMS0wKwYDVQQDDCQwMTk4ZjRiMC04YjAwLTdkODAtOTQ5MS05ZmEwYjFjMmQzZTcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYqHoZ46cBcslpM7HxBgZFVJgNYIu0kOUlTDJ7f7bUSJKwiFd/WkKhjzTi+BtUcB2S/4hw7AO/AzEH6bAoKu8MoFwwWgYJKoZIhvcNAQkOMU0wSzBJBgNVHREEQjBAhj51cm46cnVzdGZzOmNvbm5lY3Q6ZGV2aWNlOjAxOThmNGIwLThiMDAtN2Q4MC05NDkxLTlmYTBiMWMyZDNlNzAKBggqhkjOPQQDAgNIADBFAiBFV0TbGna4C83UtAaLF4Ar7E0ofknbqY0ZUDXlsB9n8gIhAKBuWIviE8mhzvzCY5kjRgAgSChxfAZ9kFSQoMHZTkXM",
|
||||
"proof": {
|
||||
"algorithm": "ES256",
|
||||
"value": "zMxwclr9Zrk4lxtPDkbrJs8LHwIStmDg7XONYNpa2As9PEXHqtHMSQPLUP4d4YB_Kcr5UdG3BU64UlwxLrYIrg"
|
||||
}
|
||||
},
|
||||
"serverTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"serverTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"expected": {
|
||||
"accepted": false,
|
||||
"reason": "REGISTRATION_PROOF_INVALID",
|
||||
"verifiesMathematically": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "registration",
|
||||
"fixture": "transcript",
|
||||
"description": "The frozen canonical byte sequence a RustFS device signs to prove possession of its device key during the registration token exchange, the frozen request body that carries it, and the order a verifier applies its checks in. protocol/agent/v1/registration-proof.md is the prose; this file is the machine-readable freeze. R01 and R02 implement against both; api/tests/Feature/Agent/RegistrationProofFixtureTest.php replays them.",
|
||||
"transcript": {
|
||||
"domain": "RUSTFS-CONNECT-REGISTRATION-V1",
|
||||
"domainEncoding": "US-ASCII, 30 octets, compared octet for octet. Case sensitive; no other spelling exists.",
|
||||
"domainTerminator": "0x0a",
|
||||
"fieldEncoding": "decimalOctetLength 0x3a valueOctets 0x0a",
|
||||
"lengthEncoding": "The shortest ASCII decimal spelling of the value length in octets: no sign, no leading zero, no padding, no separators. It counts octets and never characters.",
|
||||
"fieldSeparator": "0x3a",
|
||||
"fieldTerminator": "0x0a",
|
||||
"fieldCount": 7,
|
||||
"fieldOrder": [
|
||||
"registrationTokenUid",
|
||||
"organizationUid",
|
||||
"clusterUid",
|
||||
"requestId",
|
||||
"challengeNonce",
|
||||
"expiresUnix",
|
||||
"certificateRequestSha256"
|
||||
],
|
||||
"trailingTerminator": true,
|
||||
"isADocument": false,
|
||||
"isJson": false,
|
||||
"reserialisationPermitted": false,
|
||||
"canonicalisationPermitted": false,
|
||||
"normalisationPermitted": false,
|
||||
"rule": "transcript = domain || 0x0a || field(registrationTokenUid) || field(organizationUid) || field(clusterUid) || field(requestId) || field(challengeNonce) || field(expiresUnix) || field(certificateRequestSha256), where field(v) = decimal(octetLength(v)) || 0x3a || v || 0x0a.",
|
||||
"note": "The transcript is built, never parsed. A verifier constructs it from the registration token row it resolved plus two values the request carries, and compares nothing but the resulting signature. There is therefore no such thing as a malformed transcript on the wire: a field that cannot be spelled canonically is refused before a transcript exists.",
|
||||
"fields": [
|
||||
{
|
||||
"position": 1,
|
||||
"name": "registrationTokenUid",
|
||||
"source": "the resolved registration token row",
|
||||
"encoding": "lowercase canonical UUIDv7, 36 octets of US-ASCII",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
"binds": "the one token row this exchange spends",
|
||||
"absenceWouldAllow": "A proof produced for one token to be presented with another token of the same cluster whose nonce and expiry an attacker could otherwise reproduce, spending a credential its holder never used."
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"name": "organizationUid",
|
||||
"source": "the resolved registration token row",
|
||||
"encoding": "lowercase canonical UUIDv7, 36 octets of US-ASCII",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
"binds": "the tenant that issued the token",
|
||||
"absenceWouldAllow": "A device to be enrolled into a tenant other than the one whose operator issued its token, if a token row were ever moved between organizations by a bug or a restore. It also makes an implementation structurally unable to build a tenant-agnostic transcript."
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"name": "clusterUid",
|
||||
"source": "the resolved registration token row",
|
||||
"encoding": "lowercase canonical UUIDv7, 36 octets of US-ASCII",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
"binds": "the cluster the device joins",
|
||||
"absenceWouldAllow": "A proof to enrol a device into a different cluster of the same tenant, which is the intra-tenant half of the same substitution and is not covered by organizationUid."
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"name": "requestId",
|
||||
"source": "the request body",
|
||||
"encoding": "lowercase canonical UUIDv4, 36 octets of US-ASCII",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
"binds": "the single idempotent attempt the proof belongs to",
|
||||
"absenceWouldAllow": "A captured request body to be resubmitted under a fresh idempotency key. A registration token reservation is held by (requestId, csrHash), so a replay under a new requestId could take the exchange over once the original reservation lapsed. With requestId bound, a captured body can only ever be replayed as itself, which is exactly an idempotent retry."
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"name": "challengeNonce",
|
||||
"source": "the resolved registration token row",
|
||||
"encoding": "64 octets of lowercase hexadecimal, the 256 bit nonce as it is stored",
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"binds": "the one-time challenge delivered with the token secret",
|
||||
"absenceWouldAllow": "A proof to be precomputed from public token metadata alone. The token uid, the organization, and the cluster all appear in ordinary console responses; the nonce is delivered exactly once, beside the secret, to the operator enrolling the cluster. Without it, anyone who can read the console could build every transcript in advance and would only need a secret leaked later through some other channel."
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"name": "expiresUnix",
|
||||
"source": "the resolved registration token row",
|
||||
"encoding": "seconds since the Unix epoch as shortest ASCII decimal: no sign, no leading zero, no fraction, no padding",
|
||||
"pattern": "^[1-9][0-9]{0,18}$",
|
||||
"binds": "the enrolment window the control plane recorded",
|
||||
"absenceWouldAllow": "An expired exchange to be resurrected by widening the window it was produced against. Every proof made for the old window stops verifying the moment expires_at changes, so a row edited to extend a spent enrolment yields nothing."
|
||||
},
|
||||
{
|
||||
"position": 7,
|
||||
"name": "certificateRequestSha256",
|
||||
"source": "recomputed over the exact octets of the presented certificate request",
|
||||
"encoding": "unpadded base64url of the 32 octet SHA-256 digest, 43 octets of US-ASCII",
|
||||
"pattern": "^[A-Za-z0-9_-]{43}$",
|
||||
"binds": "the exact certificate request being answered, and through it the device public key",
|
||||
"absenceWouldAllow": "A captured proof to be presented with an attacker certificate request, so that Connect issues a device certificate for an attacker key against an operator token. This is the proof-of-possession property itself. The digest covers the transmitted octets and not a parsed structure, so a re-encoded certificate request is a different artifact rather than an equivalent one."
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"value": "the registration token secret",
|
||||
"reason": "It is proven by comparing its SHA-256 against the stored digest, and it is the one value in the exchange that must never reach a signature input, a debug dump, or an audit record. Nothing is weakened by leaving it out: the token uid already names the row, and the nonce already makes the transcript unguessable from public metadata."
|
||||
},
|
||||
{
|
||||
"value": "the certificate request octets themselves",
|
||||
"reason": "Only their digest. A signature input that carried a whole PKCS#10 would make every verifier hold the artifact in the hashing path for no additional binding."
|
||||
},
|
||||
{
|
||||
"value": "any organization, cluster, device, or expiry the client states",
|
||||
"reason": "The request body has no field for one. Every tenancy value in the transcript is read from the token row after the token is resolved, so a client cannot choose what its own proof is checked against (ADR 0002)."
|
||||
},
|
||||
{
|
||||
"value": "a keyId beside the proof",
|
||||
"reason": "The verifying key is the SubjectPublicKeyInfo inside the presented certificate request and nothing else. A keyId would be a lookup hint that is not one, and the first implementation to trust it would have re-introduced key substitution."
|
||||
},
|
||||
{
|
||||
"value": "a timestamp produced by the device",
|
||||
"reason": "Device clocks are advisory (ADR 0003). Freshness is the token row expiry evaluated against the Connect clock, which is already bound as expiresUnix."
|
||||
}
|
||||
]
|
||||
},
|
||||
"signature": {
|
||||
"signatureAlgorithm": "ES256",
|
||||
"curve": "P-256",
|
||||
"hash": "SHA-256",
|
||||
"signatureEncoding": "fixed-width-r-s",
|
||||
"signatureLengthBytes": 64,
|
||||
"signatureTransferEncoding": "base64url-unpadded",
|
||||
"signatureValuePattern": "^[A-Za-z0-9_-]{86}$",
|
||||
"lowSRequired": true,
|
||||
"groupOrder": "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551",
|
||||
"maxS": "7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8",
|
||||
"algorithmField": "proof.algorithm",
|
||||
"algorithmEnumeration": [
|
||||
"ES256"
|
||||
],
|
||||
"algorithmEnumerationIsClosed": true,
|
||||
"verifyingKey": "the SubjectPublicKeyInfo of the presented certificate request, and never a key named anywhere in the request",
|
||||
"subjectPublicKeyInfoDerPrefix": "3059301306072a8648ce3d020106082a8648ce3d030107034200",
|
||||
"note": "Identical to the encoding ADR 0009 freezes for the offline surface, so one repository has one signature encoding. Only the signature input differs, and transcript.divergenceFromAdr0009 says why."
|
||||
},
|
||||
"divergenceFromAdr0009": {
|
||||
"shared": [
|
||||
"ES256 on P-256 with SHA-256.",
|
||||
"The 64 octet fixed-width r||s encoding, low-S normalised, as unpadded base64url.",
|
||||
"A domain separation tag in front of everything, so a signature made for one surface can never be replayed on another.",
|
||||
"Closed enumerations: an unrecognised algorithm is refused, never discarded."
|
||||
],
|
||||
"differs": [
|
||||
{
|
||||
"aspect": "what is signed",
|
||||
"adr0009": "the exact raw octets of a document as transmitted",
|
||||
"here": "a transcript the verifier constructs from values it already holds",
|
||||
"reason": "ADR 0009 signs bytes because a document crosses the wire and re-serialising it before verification would verify something the producer never signed. There is no such document here. Five of the seven bound values are never transmitted at all: they are read from the registration token row after the token is resolved. A signature input assembled from a document could therefore only bind what the client chose to send, which is the precise thing this exchange must not do."
|
||||
},
|
||||
{
|
||||
"aspect": "the separator after the domain tag",
|
||||
"adr0009": "0x00, the one octet that cannot appear in an ASCII tag or in JSON",
|
||||
"here": "0x0a, once after the domain and once after every field",
|
||||
"reason": "ADR 0009 needs an octet that cannot occur in either half because the second half is an opaque document of unbounded shape. Here every field is length-prefixed and the arity is fixed at seven, so the parse is unambiguous whatever the separator is; the separator only has to be a byte no field value can contain, and none of the seven patterns admits 0x0a. Choosing 0x0a instead makes the whole transcript printable US-ASCII, which is why this fixture can publish the literal canonical string beside its digest and ADR 0009 cannot."
|
||||
}
|
||||
],
|
||||
"note": "These are two signature inputs, not two signing conventions. An implementation that already produces ADR 0009 signatures changes only what it hashes."
|
||||
},
|
||||
"request": {
|
||||
"operation": "POST /agent/registrationTokens:exchange",
|
||||
"authentication": "none; ADR 0008 lets only a pre-registration operation opt out, because a device has no certificate until this exchange gives it one",
|
||||
"contentType": "application/json",
|
||||
"abuseGate": "api/app/Modules/Agent/Http/Middleware/RegistrationAbuseGuard.php, which runs before this body is parsed",
|
||||
"registrationTokenUidMustAppearWithinFirstBytes": 1024,
|
||||
"registrationTokenUidPlacementNote": "The abuse gate finds the token uid by scanning at most the first 1024 octets of the body, so a producer MUST place registrationTokenUid inside that window. A body that does not is not rejected; it silently loses its per-token rate bucket and is bounded by source address alone. That is why this is a stated requirement and not left to JSON member order being unobservable.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"rule": "v<major>, exactly as protocol/agent/v1/authentication.md freezes it. Anything else is UNSUPPORTED_PROTOCOL and HTTP 400 with nothing partially processed."
|
||||
},
|
||||
{
|
||||
"name": "requestId",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"rule": "Lowercase canonical UUIDv4 idempotency key, bound into the transcript at position 4 and into the token reservation."
|
||||
},
|
||||
{
|
||||
"name": "registrationTokenUid",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"rule": "The public lookup half of the token. Not a secret, and not authorization evidence: it selects a row and nothing more."
|
||||
},
|
||||
{
|
||||
"name": "registrationTokenSecret",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"rule": "The 256 bit secret as unpadded base64url, compared in constant time against the stored SHA-256 digest. It is never part of the transcript and no fixture in this set carries one."
|
||||
},
|
||||
{
|
||||
"name": "certificateRequest",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"rule": "PKCS#10 DER as standard padded base64. Its digest is bound at position 7 and its SubjectPublicKeyInfo is the verifying key."
|
||||
},
|
||||
{
|
||||
"name": "proof",
|
||||
"required": true,
|
||||
"type": "object",
|
||||
"rule": "Exactly two members: algorithm, fixed at ES256, and value, the 64 octet r||s proof as unpadded base64url."
|
||||
}
|
||||
],
|
||||
"absentByConstruction": [
|
||||
"organizationUid",
|
||||
"organizationName",
|
||||
"clusterUid",
|
||||
"clusterName",
|
||||
"clusterDeviceUid",
|
||||
"challengeNonce",
|
||||
"expiresUnix",
|
||||
"proof.keyId"
|
||||
],
|
||||
"reservationCertificateRequestHash": "lowercase SHA-256 hex over the same certificate request octets that position 7 digests",
|
||||
"reservationCertificateRequestHashNote": "RegistrationToken::isReservableBy() holds a reservation under (requestId, csrHash). The reservation and the transcript must digest the same octets the same way, or one request could hold a token for a certificate request its proof does not cover. Same input, same algorithm, different transfer encoding only because one value is a database column and the other is a transcript field.",
|
||||
"absentByConstructionNote": "There is no field for any of these, so no implementation can accept one \"just to compare it\". Connect reads all of them from the token row.",
|
||||
"certificateRequestProfile": {
|
||||
"format": "PKCS#10, DER",
|
||||
"publicKey": "ECDSA on NIST P-256",
|
||||
"selfSignature": "ES256 by the key it presents, verified over the DER-encoded certificationRequestInfo",
|
||||
"selfSignatureEncodingConstrained": false,
|
||||
"selfSignatureEncodingNote": "The PKCS#10 self-signature is ordinary ASN.1 DER and is not held to the r||s or low-S rules; it is not an artifact identity, and its exact octets are already bound by the position 7 digest. Two certificate requests that differ only in their self-signature are two different artifacts, each with its own transcript.",
|
||||
"subjectUsed": false,
|
||||
"sanUsed": false,
|
||||
"extensionsUsed": false,
|
||||
"attributesUsed": false,
|
||||
"claimedDeviceUidInFixtures": "0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7",
|
||||
"claimedSubjectAlternativeNameInFixtures": "urn:rustfs:connect:device:0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7",
|
||||
"claimedIdentityNote": "Every certificate request in this set carries the subject CN=0198f4b0-8b00-7d80-9491-9fa0b1c2d3e7 and the matching device URN as its only subject alternative name. Connect assigned no such device, and no vector references that uid anywhere else. A verifier that reads an identity out of a certificate request will visibly agree with a value nothing else in the exchange corroborates, which is easier to notice than an omission.",
|
||||
"ignoredFieldsNote": "Connect consumes a certificate request for its SubjectPublicKeyInfo and its self-signature and for nothing else. The subject, the subject alternative names, any requested extensions, and any attributes are ignored and are never copied into the issued certificate. A device cannot name itself: ADR 0008 fixes the issued subject as CN=<clusterDeviceUid> and the SAN as urn:rustfs:connect:device:<clusterDeviceUid>, and Connect assigns that uid during this exchange. A device has no uid to put in a certificate request, which is the structural reason the request cannot be the source of its own identity.",
|
||||
"selfSignatureAloneIsInsufficient": "A valid self-signature proves only that somebody holds the key in the request. It binds no token, no tenant, no cluster, and no attempt, so a verifier that stopped there would issue a device certificate to any key presented with any stolen token. reject-vectors.json publishes exactly that vector under \"accepted proof presented with a substituted certificate request\"."
|
||||
}
|
||||
},
|
||||
"verificationOrder": {
|
||||
"principle": "Refuse on what can be refused without a database read, then resolve the token, then verify the proof. The order is not a preference: four of the seven transcript fields exist only in the token row, so no signature can be checked before that row is resolved.",
|
||||
"steps": [
|
||||
"read protocolVersion and refuse an unsupported major version with UNSUPPORTED_PROTOCOL",
|
||||
"refuse a proof.algorithm other than ES256 with UNSUPPORTED_ALGORITHM",
|
||||
"refuse a proof.value that is not 86 base64url characters decoding to 64 octets with r and s in [1, n) with SIGNATURE_MALFORMED",
|
||||
"refuse a proof.value whose s exceeds half the group order with SIGNATURE_NOT_CANONICAL, before any key is loaded",
|
||||
"decode the certificate request, refuse anything that is not one well-formed PKCS#10 DER with no trailing octets with CERTIFICATE_REQUEST_MALFORMED",
|
||||
"refuse a SubjectPublicKeyInfo that is not an ECDSA key on P-256 with DEVICE_KEY_UNSUPPORTED",
|
||||
"refuse a certificate request whose ES256 self-signature does not verify under its own key with CERTIFICATE_REQUEST_MALFORMED",
|
||||
"resolve the registration token by uid and secret digest and refuse anything not usable now with REGISTRATION_TOKEN_UNUSABLE",
|
||||
"rebuild the transcript from the resolved row plus requestId and the recomputed certificate request digest",
|
||||
"verify the proof over those octets under the certificate request key and refuse with REGISTRATION_PROOF_INVALID"
|
||||
],
|
||||
"ownedByThisContract": [
|
||||
"UNSUPPORTED_ALGORITHM",
|
||||
"SIGNATURE_MALFORMED",
|
||||
"SIGNATURE_NOT_CANONICAL",
|
||||
"CERTIFICATE_REQUEST_MALFORMED",
|
||||
"DEVICE_KEY_UNSUPPORTED",
|
||||
"REGISTRATION_PROOF_INVALID"
|
||||
],
|
||||
"ownedElsewhere": [
|
||||
{
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"owner": "protocol/agent/v1/authentication.md"
|
||||
},
|
||||
{
|
||||
"reason": "REGISTRATION_TOKEN_UNUSABLE",
|
||||
"owner": "App\\Modules\\Clusters\\Application\\Contracts\\RegistrationTokenPort"
|
||||
}
|
||||
],
|
||||
"note": "A rejection never says which of the seven bindings disagreed. All of them collapse into REGISTRATION_PROOF_INVALID, because a response that distinguished them would tell an unauthenticated caller which of its guesses about a token row was right."
|
||||
},
|
||||
"example": {
|
||||
"note": "The first accept vector, written out. A producer that reproduces these octets from these inputs has a correct transcript builder and has not needed a single line of cryptography to prove it.",
|
||||
"inputs": {
|
||||
"registrationTokenUid": "0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"organizationUid": "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"clusterUid": "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"requestId": "3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"challengeNonce": "a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
"expiresUnix": 1787228100,
|
||||
"certificateRequestSha256": "H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4"
|
||||
},
|
||||
"canonicalTranscript": "RUSTFS-CONNECT-REGISTRATION-V1\n36:0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5\n36:0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81\n36:3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b\n64:a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f\n10:1787228100\n43:H3RBnh-SfmAAnMKVdPKlQeWuuexx2_yY_c0t1TSddo4\n",
|
||||
"canonicalTranscriptLengthBytes": 320,
|
||||
"canonicalTranscriptSha256": "2552ab455a86703b75321cf70c7ee48034f0ad8a56dae0dbdc462c7fbe762477",
|
||||
"canonicalTranscriptBase64": "UlVTVEZTLUNPTk5FQ1QtUkVHSVNUUkFUSU9OLVYxCjM2OjAxOThmNGIwLTZmMDAtN2I2MC05MjcxLTdkOGU5ZmEwYjFjNQozNjowMTk4ZjRiMC0xYTAwLTdjMTAtOGQyMS0yZTNmNGE1YjZjNzAKMzY6MDE5OGY0YjAtMmIwMC03ZDIwLTllMzEtM2Y0YTViNmM3ZDgxCjM2OjNmMmExYzk0LTViNmQtNGU4Zi05YTBiLTFjMmQzZTRmNWE2Ygo2NDphM2YxYzA3ZDliMmU0ODU2YWYwYzFkM2I1ZTdmOTAxMmM0YTZiOGQwZTJmNDA2MTczODQ5NWE2YjdjOGQ5ZTBmCjEwOjE3ODcyMjgxMDAKNDM6SDNSQm5oLVNmbUFBbk1LVmRQS2xRZVd1dWV4eDJfeVlfYzB0MVRTZGRvNAo=",
|
||||
"proof": "iULWfq3BzJQ2mIqFkZomPHAXahWjbUP1ETO8KBIRr-s9RnUdst7MP_kuaizIZozfAmhaKIOImCejwpptE_9atQ"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
610eeaf44cb7a9a4ed2e4f076c2aec6050a5b81e873fa5c97845155b7ee727a2 additive-compatibility.json
|
||||
3c9453cdbb34557d08ac63a1e2d7024870c8794e7d0cdc48c3a7fdfd2fa8b15f field-registry.json
|
||||
3c7fe86634b7c85da87865498a6677968a78fad1be6d69ed5b3607c29d47430e negotiation-vectors.json
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "version",
|
||||
"fixture": "additive-compatibility",
|
||||
"description": "Release skew in both directions. v1 grows by optional fields only, so an unknown field is discarded and an absent one takes its documented default.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "new agent sends a v1 field this Connect does not know",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"payload": {
|
||||
"protocolVersion": "v1",
|
||||
"agentVersion": "1.9.0",
|
||||
"capabilities": ["heartbeat"],
|
||||
"telemetryProfile": "extended"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"retained": ["protocolVersion", "agentVersion", "capabilities"],
|
||||
"discarded": ["telemetryProfile"],
|
||||
"defaultsApplied": {},
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "new agent sends several unknown optional fields at once",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"payload": {
|
||||
"protocolVersion": "v1",
|
||||
"capabilities": ["heartbeat", "inventory", "bundle.upload"],
|
||||
"telemetryProfile": "extended",
|
||||
"regionHint": "eu-west",
|
||||
"experimentalFlags": {
|
||||
"fastHeartbeat": true
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"retained": ["protocolVersion", "capabilities"],
|
||||
"discarded": ["telemetryProfile", "regionHint", "experimentalFlags"],
|
||||
"defaultsApplied": {
|
||||
"agentVersion": null
|
||||
},
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "old agent omits every optional field",
|
||||
"direction": "old-agent-to-new-connect",
|
||||
"payload": {
|
||||
"protocolVersion": "v1"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"retained": ["protocolVersion"],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {
|
||||
"agentVersion": null,
|
||||
"capabilities": []
|
||||
},
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "old agent reports no capabilities but names itself",
|
||||
"direction": "old-agent-to-new-connect",
|
||||
"payload": {
|
||||
"protocolVersion": "v1",
|
||||
"agentVersion": "1.0.0"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"retained": ["protocolVersion", "agentVersion"],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {
|
||||
"capabilities": []
|
||||
},
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown fields do not rescue an unsupported major version",
|
||||
"direction": "new-agent-to-old-connect",
|
||||
"payload": {
|
||||
"protocolVersion": "v2",
|
||||
"agentVersion": "2.0.0",
|
||||
"compatibilityShim": "v1"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400,
|
||||
"retained": [],
|
||||
"discarded": [],
|
||||
"defaultsApplied": {},
|
||||
"echoedBack": [],
|
||||
"stored": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "version",
|
||||
"fixture": "field-registry",
|
||||
"description": "The frozen v1 negotiation envelope. Registration and heartbeat both carry it. Any top-level field not listed here is unknown and is discarded after acceptance.",
|
||||
"envelope": "AgentProtocolNegotiation",
|
||||
"supportedMajorVersions": [1],
|
||||
"protocolVersionPattern": "^v[1-9][0-9]{0,3}$",
|
||||
"unknownFieldPolicy": "accept-and-discard",
|
||||
"unknownCapabilityPolicy": "discard",
|
||||
"fields": [
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"requiredness": "required",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Major version only. The minor and patch level of an agent is not negotiated."
|
||||
},
|
||||
{
|
||||
"name": "agentVersion",
|
||||
"requiredness": "optional",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"note": "Informational. Connect never compares it for equality with its own version and never gates behavior on it."
|
||||
},
|
||||
{
|
||||
"name": "capabilities",
|
||||
"requiredness": "optional",
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"note": "Unordered token set. A capability an operation requires but the device did not report fails that operation with a structured result, not the connection."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"fixtureSet": "version",
|
||||
"fixture": "negotiation-vectors",
|
||||
"description": "Protocol version decisions. A rejected version fails closed: nothing in the payload is processed, stored, or echoed.",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "supported major version",
|
||||
"request": {
|
||||
"protocolVersion": "v1",
|
||||
"agentVersion": "1.4.0",
|
||||
"capabilities": ["heartbeat", "inventory"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"negotiatedProtocolVersion": "v1",
|
||||
"reason": null,
|
||||
"httpStatus": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "supported major version reported by an agent that sends nothing else",
|
||||
"request": {
|
||||
"protocolVersion": "v1"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "ACCEPT",
|
||||
"negotiatedProtocolVersion": "v1",
|
||||
"reason": null,
|
||||
"httpStatus": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "next major version from a future agent",
|
||||
"request": {
|
||||
"protocolVersion": "v2",
|
||||
"agentVersion": "2.0.0",
|
||||
"capabilities": ["heartbeat"]
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "far future major version",
|
||||
"request": {
|
||||
"protocolVersion": "v9999"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing protocol version",
|
||||
"request": {
|
||||
"agentVersion": "1.4.0"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "version without its prefix",
|
||||
"request": {
|
||||
"protocolVersion": "1"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uppercase prefix",
|
||||
"request": {
|
||||
"protocolVersion": "V1"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dotted version",
|
||||
"request": {
|
||||
"protocolVersion": "v1.2"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "zero major version",
|
||||
"request": {
|
||||
"protocolVersion": "v0"
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty protocol version",
|
||||
"request": {
|
||||
"protocolVersion": ""
|
||||
},
|
||||
"expected": {
|
||||
"decision": "REJECT",
|
||||
"negotiatedProtocolVersion": null,
|
||||
"reason": "UNSUPPORTED_PROTOCOL",
|
||||
"httpStatus": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -244,10 +244,6 @@ rustfs-concurrency = { workspace = true }
|
||||
rustfs-scanner = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
# Connect device identity: P-256 keys, PKCS#10 certificate requests, ES256 proofs.
|
||||
p256 = { version = "0.13.2", features = ["ecdsa", "pkcs8"] }
|
||||
rcgen = { workspace = true }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-trait = { workspace = true }
|
||||
axum.workspace = true
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::{
|
||||
auth::validate_admin_request,
|
||||
auth::authorize_admin_request,
|
||||
handlers::audit_runtime_config::{load_server_config_from_store, update_audit_config_and_reload},
|
||||
handlers::target_descriptor::{
|
||||
AdminTargetSpec, EndpointKey, RuntimeHealthStatus, TargetEndpointSource, admin_target_spec_from_builtin,
|
||||
@@ -23,9 +23,8 @@ use crate::admin::{
|
||||
},
|
||||
router::{AdminOperation, Operation, S3Router},
|
||||
};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{
|
||||
ADMIN_PREFIX, RemoteAddr, is_audit_module_enabled, refresh_audit_module_enabled, refresh_persisted_module_switches_from_store,
|
||||
ADMIN_PREFIX, is_audit_module_enabled, refresh_audit_module_enabled, refresh_persisted_module_switches_from_store,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
@@ -213,14 +212,14 @@ fn audit_target_specs() -> &'static [AdminTargetSpec] {
|
||||
&AUDIT_TARGET_SPECS
|
||||
}
|
||||
|
||||
/// The pre-check keeps these endpoints' historical missing-credentials message;
|
||||
/// the shared gate reports "get cred failed".
|
||||
async fn authorize_audit_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = &req.credentials else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "credentials not found"));
|
||||
};
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await
|
||||
}
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_target_mutation_block_reason(config: &Config, target_type: &str, target_name: &str) -> S3Result<Option<String>> {
|
||||
@@ -824,6 +823,30 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// These endpoints authorize through the shared admin gate, which reports
|
||||
/// "get cred failed" for a credential-less request. The pre-check keeps the
|
||||
/// message they have always returned (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn audit_target_gate_keeps_its_missing_credentials_message() {
|
||||
let req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: Method::PUT,
|
||||
uri: http::Uri::from_static("/rustfs/admin/v3/audit/target"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = authorize_audit_admin_request(&req, AdminAction::SetBucketTargetAction)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("credentials not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_target_handlers_require_admin_authorization_contract() {
|
||||
let src = include_str!("audit.rs");
|
||||
|
||||
@@ -23,11 +23,10 @@
|
||||
//! backing infrastructure (in-process log ring buffer, cross-node object
|
||||
//! speedtest harness).
|
||||
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::storage_api::access::spawn_traced;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::storage::storage_api::get_global_lock_clients;
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, StreamExt, future::join_all};
|
||||
@@ -133,16 +132,15 @@ pub fn register_diagnostics_route(r: &mut S3Router<AdminOperation>) -> std::io::
|
||||
// Shared auth helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The pre-check keeps these endpoints' historical `AccessDenied` missing-credentials
|
||||
/// response; the shared gate reports `InvalidRequest` "get cred failed".
|
||||
async fn authorize(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(AccessDenied, "Signature is required"));
|
||||
};
|
||||
}
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, value: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
@@ -1078,6 +1076,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// These endpoints authorize through the shared admin gate, which rejects a
|
||||
/// credential-less request with `InvalidRequest` "get cred failed". The
|
||||
/// pre-check keeps the `AccessDenied` response they have always returned
|
||||
/// (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn diagnostics_gate_keeps_its_missing_credentials_response() {
|
||||
let err = authorize(
|
||||
&build_request(Method::GET, "/rustfs/admin/v3/top/locks"),
|
||||
AdminAction::ServerInfoAdminAction,
|
||||
)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn top_locks_handler_rejects_missing_credentials() {
|
||||
let err = TopLocksHandler {}
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::admin::storage_api::bucket::metadata::BUCKET_DURABILITY_CONFIG;
|
||||
use crate::admin::storage_api::bucket::metadata_sys;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::RemoteAddr;
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
@@ -126,13 +127,14 @@ async fn authenticate_admin(req: &S3Request<Body>) -> S3Result<()> {
|
||||
|
||||
let (cred, owner) = check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ConfigUpdateAdminAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::{
|
||||
auth::validate_admin_request,
|
||||
auth::authorize_admin_request,
|
||||
handlers::notify_runtime_access::{get_notification_system, load_notification_config_snapshot},
|
||||
handlers::supervise_admin_mutation,
|
||||
handlers::target_descriptor::{
|
||||
@@ -26,10 +26,8 @@ use crate::admin::{
|
||||
runtime_sources::{AppContext, app_context_from_req},
|
||||
service::config::{preflight_dynamic_config_reload_for_context, signal_dynamic_config_reload_checked_for_context},
|
||||
};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{
|
||||
ADMIN_PREFIX, RemoteAddr, is_notify_module_enabled, refresh_notify_module_enabled,
|
||||
refresh_persisted_module_switches_from_store,
|
||||
ADMIN_PREFIX, is_notify_module_enabled, refresh_notify_module_enabled, refresh_persisted_module_switches_from_store,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
@@ -264,14 +262,14 @@ fn notification_target_specs() -> &'static [AdminTargetSpec] {
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/// The pre-check keeps these endpoints' historical missing-credentials message;
|
||||
/// the shared gate reports "get cred failed".
|
||||
async fn authorize_notification_admin_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = &req.credentials else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "credentials not found"));
|
||||
};
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await
|
||||
}
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_mutation_block_reason(config: &Config, target_type: &str, target_name: &str) -> S3Result<Option<String>> {
|
||||
@@ -987,6 +985,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// These endpoints authorize through the shared admin gate, which reports
|
||||
/// "get cred failed" for a credential-less request. The pre-check keeps the
|
||||
/// message they have always returned (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn notification_target_gate_keeps_its_missing_credentials_message() {
|
||||
let req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: Method::PUT,
|
||||
uri: http::Uri::from_static("/rustfs/admin/v3/notification/target"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = authorize_notification_admin_request(&req, AdminAction::SetBucketTargetAction)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("credentials not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_target_handlers_require_admin_authorization_contract() {
|
||||
let src = include_str!("event.rs");
|
||||
|
||||
@@ -18,12 +18,10 @@
|
||||
//! keeping the response format explicitly NDJSON. It is not a Prometheus text
|
||||
//! exposition endpoint.
|
||||
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::router::Operation;
|
||||
use crate::admin::storage_api::access::spawn_traced;
|
||||
use crate::admin::storage_api::metrics::{CollectMetricsOpts, MetricType, collect_local_metrics};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::RemoteAddr;
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, StreamExt};
|
||||
use http::{HeaderMap, HeaderValue, Uri};
|
||||
@@ -182,24 +180,15 @@ impl ByteStream for MetricsStream {}
|
||||
|
||||
pub struct MetricsHandler {}
|
||||
|
||||
/// The pre-check keeps this endpoint's historical `AccessDenied` missing-credentials
|
||||
/// response; the shared gate reports `InvalidRequest` "get cred failed".
|
||||
async fn authorize_metrics_request(req: &S3Request<Body>) -> S3Result<()> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(AccessDenied, "Signature is required"));
|
||||
};
|
||||
}
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::GetMetricsAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await
|
||||
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::GetMetricsAction)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -17,14 +17,13 @@ use crate::admin::service::config::{
|
||||
preflight_dynamic_config_reload_for_context, signal_dynamic_config_reload_checked_for_context,
|
||||
};
|
||||
use crate::admin::{
|
||||
auth::validate_admin_request,
|
||||
auth::authorize_admin_request,
|
||||
handlers::supervise_admin_mutation,
|
||||
router::{AdminOperation, Operation, S3Router},
|
||||
};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{
|
||||
ADMIN_PREFIX, MODULE_SWITCHES_SIGNAL_SUBSYSTEM, ModuleSwitchSnapshot, ModuleSwitchSource, PersistedModuleSwitches,
|
||||
RemoteAddr, apply_audit_module_switch_for_context, current_module_switch_snapshot, mark_event_notifier_reconciled,
|
||||
apply_audit_module_switch_for_context, current_module_switch_snapshot, mark_event_notifier_reconciled,
|
||||
mark_event_notifier_unreconciled, refresh_audit_module_enabled, refresh_notify_module_enabled,
|
||||
refresh_persisted_module_switches_from, refresh_persisted_module_switches_from_store, save_persisted_module_switches_to,
|
||||
validate_module_switch_update,
|
||||
@@ -114,23 +113,15 @@ fn build_response<T: Serialize>(
|
||||
Ok(S3Response::with_headers((status, Body::from(data)), header))
|
||||
}
|
||||
|
||||
/// The pre-check keeps these endpoints' historical missing-credentials message;
|
||||
/// the shared gate reports "get cred failed".
|
||||
async fn authorize_module_switch_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = &req.credentials else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||
};
|
||||
}
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(action)],
|
||||
req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0)),
|
||||
)
|
||||
.await
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_module_switch_snapshot() -> S3Result<ModuleSwitchSnapshot> {
|
||||
@@ -269,6 +260,30 @@ impl Operation for UpdateModuleSwitchesHandler {
|
||||
mod tests {
|
||||
use super::{ModuleSwitchDiscovery, ModuleSwitchSource, ModuleSwitchesResponse};
|
||||
|
||||
/// These endpoints authorize through the shared admin gate, which reports
|
||||
/// "get cred failed" for a credential-less request. The pre-check keeps the
|
||||
/// message they have always returned (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn module_switch_gate_keeps_its_missing_credentials_message() {
|
||||
let req = s3s::S3Request {
|
||||
input: s3s::Body::from(String::new()),
|
||||
method: http::Method::GET,
|
||||
uri: http::Uri::from_static("/rustfs/admin/v3/module-switches"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = super::authorize_module_switch_request(&req, rustfs_policy::policy::action::AdminAction::ServerInfoAdminAction)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("authentication required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_switch_handlers_require_admin_authorization_contract() {
|
||||
let src = include_str!("module_switch.rs");
|
||||
|
||||
@@ -12,33 +12,22 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::{auth::validate_admin_request, router::Operation};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::admin::{auth::authorize_admin_request, router::Operation};
|
||||
use http::StatusCode;
|
||||
use matchit::Params;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::{Body, S3Request, S3Response, S3Result, s3_error};
|
||||
use tracing::info;
|
||||
|
||||
/// The pre-check keeps these endpoints' historical `AccessDenied` missing-credentials
|
||||
/// response; the shared gate reports `InvalidRequest` "get cred failed".
|
||||
pub(super) async fn authorize_profile_request(req: &S3Request<Body>) -> S3Result<()> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(AccessDenied, "Signature is required"));
|
||||
};
|
||||
}
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ProfilingAdminAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await
|
||||
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ProfilingAdminAction)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn profile_not_implemented_response(message: String) -> S3Response<(StatusCode, Body)> {
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::profile::{authorize_profile_request, profile_not_implemented_response};
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::storage_api::access::spawn_traced;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, StreamExt};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
@@ -89,14 +88,14 @@ pub fn register_profiling_route(r: &mut S3Router<AdminOperation>) -> std::io::Re
|
||||
}
|
||||
|
||||
/// Authorize a request against a single admin action (profiling or trace).
|
||||
/// The pre-check keeps these endpoints' historical `AccessDenied` missing-credentials
|
||||
/// response; the shared gate reports `InvalidRequest` "get cred failed".
|
||||
async fn authorize_action(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(AccessDenied, "Signature is required"));
|
||||
};
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await
|
||||
}
|
||||
authorize_admin_request(req, vec![Action::AdminAction(action)]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ProfileHandler {}
|
||||
@@ -530,7 +529,7 @@ fn trace_value_string(value: &TraceVal) -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
ProfileControlHandler, ProfileHandler, ProfileStatusHandler, ProfilingDownloadHandler, ProfilingStartHandler,
|
||||
TraceHandler, TraceKindFilter, TraceStreamFilter, TraceWireRecord,
|
||||
TraceHandler, TraceKindFilter, TraceStreamFilter, TraceWireRecord, authorize_action,
|
||||
};
|
||||
use crate::admin::router::Operation;
|
||||
use http::{Extensions, HeaderMap, Uri};
|
||||
@@ -539,6 +538,7 @@ mod tests {
|
||||
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind};
|
||||
use rustfs_madmin::service_commands::ServiceTraceOpts;
|
||||
use rustfs_madmin::trace::TraceType;
|
||||
use rustfs_policy::policy::action::AdminAction;
|
||||
use s3s::{Body, S3ErrorCode, S3Request, S3Result};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
@@ -563,6 +563,22 @@ mod tests {
|
||||
TraceStreamFilter::from_request(&uri, &opts)
|
||||
}
|
||||
|
||||
/// The profiling/trace endpoints authorize through the shared admin gate, which
|
||||
/// rejects a credential-less request with `InvalidRequest` "get cred failed". The
|
||||
/// pre-check keeps the `AccessDenied` response they have always returned
|
||||
/// (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn profile_admin_gate_keeps_its_missing_credentials_response() {
|
||||
let err = authorize_action(
|
||||
&build_profile_request("/rustfs/admin/v3/profiling/start"),
|
||||
AdminAction::ProfilingAdminAction,
|
||||
)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
|
||||
assert_eq!(err.message(), Some("Signature is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_handler_rejects_missing_credentials() {
|
||||
let result = ProfileHandler {}
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::admin::storage_api::bucket::quota::{BucketQuota, QuotaError, QuotaOpe
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use crate::server::RemoteAddr;
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
use rustfs_madmin::{SITE_REPL_API_VERSION, SRBucketMeta};
|
||||
@@ -264,13 +265,14 @@ impl Operation for SetBucketQuotaHandler {
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::SetBucketQuotaAdminAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -393,13 +395,14 @@ impl Operation for GetBucketQuotaHandler {
|
||||
if bucket.is_empty() {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request_with_bucket(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::S3Action(S3Action::GetBucketQuotaAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
&bucket,
|
||||
)
|
||||
.await?;
|
||||
@@ -461,13 +464,14 @@ impl Operation for ClearBucketQuotaHandler {
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::SetBucketQuotaAdminAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -577,13 +581,14 @@ impl Operation for GetBucketQuotaStatsHandler {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request_with_bucket(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::S3Action(S3Action::GetBucketQuotaAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
&bucket,
|
||||
)
|
||||
.await?;
|
||||
@@ -649,13 +654,14 @@ impl Operation for CheckBucketQuotaHandler {
|
||||
return Err(s3_error!(InvalidRequest, "bucket name is required"));
|
||||
}
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request_with_bucket(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::S3Action(S3Action::GetBucketQuotaAction)],
|
||||
None,
|
||||
remote_addr,
|
||||
&bucket,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::current_scanner_metrics_report;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::module_switches::{ENV_SCANNER_ENABLED, scanner_enabled_from_env};
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use chrono::Utc;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use hyper::{Method, StatusCode};
|
||||
@@ -154,29 +153,14 @@ pub fn register_scanner_route(r: &mut S3Router<AdminOperation>) -> std::io::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The pre-check keeps these endpoints' historical missing-credentials message;
|
||||
/// the shared gate reports "get cred failed".
|
||||
async fn validate_scanner_status_request(req: &S3Request<Body>) -> S3Result<Credentials> {
|
||||
let Some(input_cred) = req.credentials.as_ref() else {
|
||||
if req.credentials.is_none() {
|
||||
return Err(s3_error!(InvalidRequest, "missing credentials"));
|
||||
};
|
||||
}
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req
|
||||
.extensions
|
||||
.get::<Option<RemoteAddr>>()
|
||||
.and_then(|opt| opt.map(|addr| addr.0));
|
||||
validate_admin_request(
|
||||
&req.headers,
|
||||
&cred,
|
||||
owner,
|
||||
false,
|
||||
vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)],
|
||||
remote_addr,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(cred)
|
||||
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]).await
|
||||
}
|
||||
|
||||
fn json_response(body: Vec<u8>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
@@ -229,6 +213,30 @@ impl Operation for IlmExpiryStatusHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// These endpoints authorize through the shared admin gate, which reports
|
||||
/// "get cred failed" for a credential-less request. The pre-check keeps the
|
||||
/// message they have always returned (rustfs/backlog#1829).
|
||||
#[tokio::test]
|
||||
async fn scanner_status_gate_keeps_its_missing_credentials_message() {
|
||||
let req = S3Request {
|
||||
input: Body::from(String::new()),
|
||||
method: Method::GET,
|
||||
uri: http::Uri::from_static("/rustfs/admin/v3/scanner/status"),
|
||||
headers: HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = validate_scanner_status_request(&req)
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("missing credentials"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_disabled_reason_reports_startup_env_key() {
|
||||
assert_eq!(scanner_disabled_reason(true), None);
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
//! usage caches, with a one-level sub-prefix breakdown — the data console
|
||||
//! buckets view MinIO serves from `loadPrefixUsageFromBackend`.
|
||||
|
||||
use crate::admin::auth::validate_admin_request;
|
||||
use crate::admin::auth::authorize_admin_request;
|
||||
use crate::admin::handlers::system::data_usage_info_gate_actions;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::server::{ADMIN_PREFIX, RemoteAddr};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
@@ -70,15 +69,10 @@ fn parse_usage_prefix_query(query: Option<&str>) -> S3Result<(String, usize)> {
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for BucketPrefixUsageHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let Some(input_cred) = req.credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let (cred, owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
|
||||
|
||||
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
|
||||
validate_admin_request(&req.headers, &cred, owner, false, data_usage_info_gate_actions(), remote_addr).await?;
|
||||
// The shared gate reports the same `InvalidRequest` "get cred failed" this
|
||||
// handler has always returned for a credential-less request, so it needs no
|
||||
// message-preserving pre-check.
|
||||
authorize_admin_request(&req, data_usage_info_gate_actions()).await?;
|
||||
|
||||
let bucket = params.get("bucket").unwrap_or_default().to_string();
|
||||
if bucket.is_empty() {
|
||||
@@ -104,13 +98,40 @@ impl Operation for BucketPrefixUsageHandler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DEFAULT_MAX_ENTRIES, MAX_ENTRIES_LIMIT, parse_usage_prefix_query};
|
||||
use super::{BucketPrefixUsageHandler, DEFAULT_MAX_ENTRIES, MAX_ENTRIES_LIMIT, parse_usage_prefix_query};
|
||||
use crate::admin::router::Operation;
|
||||
use s3s::S3Error;
|
||||
|
||||
fn query(raw: &str) -> Result<(String, usize), S3Error> {
|
||||
parse_usage_prefix_query(Some(raw))
|
||||
}
|
||||
|
||||
/// This endpoint authorizes through the shared admin gate, whose
|
||||
/// credential-less rejection is the same `InvalidRequest` "get cred failed"
|
||||
/// the handler returned inline before (rustfs/backlog#1829), so no
|
||||
/// message-preserving pre-check is needed here.
|
||||
#[tokio::test]
|
||||
async fn prefix_usage_handler_keeps_its_missing_credentials_message() {
|
||||
let req = s3s::S3Request {
|
||||
input: s3s::Body::from(String::new()),
|
||||
method: http::Method::GET,
|
||||
uri: http::Uri::from_static("/rustfs/admin/v3/usage/bucket"),
|
||||
headers: http::HeaderMap::new(),
|
||||
extensions: http::Extensions::new(),
|
||||
credentials: None,
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
};
|
||||
|
||||
let err = BucketPrefixUsageHandler {}
|
||||
.call(req, matchit::Params::new())
|
||||
.await
|
||||
.expect_err("a request without credentials must be rejected");
|
||||
assert_eq!(err.code(), &s3s::S3ErrorCode::InvalidRequest);
|
||||
assert_eq!(err.message(), Some("get cred failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_apply_when_no_query_is_given() {
|
||||
assert_eq!(parse_usage_prefix_query(None).unwrap(), (String::new(), DEFAULT_MAX_ENTRIES));
|
||||
|
||||
@@ -1056,9 +1056,22 @@ pin_project! {
|
||||
remaining: usize,
|
||||
emitted: usize,
|
||||
expected: usize,
|
||||
// Diagnostic-only identity for the body this stream is serving. Unset in
|
||||
// unit tests that drive the stream over a bare reader; every production
|
||||
// body carries it via `with_diagnostics`.
|
||||
diagnostics: GetObjectReaderStreamDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
/// Object identity carried alongside a streaming GET body purely so a
|
||||
/// mid-stream failure names the object it happened on.
|
||||
#[derive(Clone, Default)]
|
||||
struct GetObjectReaderStreamDiagnostics {
|
||||
bucket: String,
|
||||
object: String,
|
||||
request_id: String,
|
||||
}
|
||||
|
||||
impl MemoryTrackedBytesStream {
|
||||
fn new(
|
||||
bytes: Bytes,
|
||||
@@ -1107,8 +1120,19 @@ where
|
||||
remaining,
|
||||
emitted: 0,
|
||||
expected: remaining,
|
||||
diagnostics: GetObjectReaderStreamDiagnostics::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the object identity a failed body should be reported against.
|
||||
fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self {
|
||||
self.diagnostics = GetObjectReaderStreamDiagnostics {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
request_id: request_id.to_string(),
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Stream for MemoryTrackedBytesStream {
|
||||
@@ -1569,12 +1593,29 @@ where
|
||||
*this.emitted,
|
||||
*this.remaining,
|
||||
);
|
||||
#[cfg(feature = "tracing-chunk-debug")]
|
||||
tracing::error!(
|
||||
emitted = *this.emitted,
|
||||
// The inner GetObjectStreamingReader is what normally reports a
|
||||
// short body, so reaching this arm means the reader signalled a
|
||||
// clean EOF while this layer still owed bytes against an
|
||||
// already-committed Content-Length. That disagreement is a data
|
||||
// plane fault, not chunk noise: log it unconditionally so the
|
||||
// truncated object is named in the operator's log rather than
|
||||
// only in a metric counter (issue #4784).
|
||||
error!(
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %this.diagnostics.bucket,
|
||||
object = %this.diagnostics.object,
|
||||
request_id = %this.diagnostics.request_id,
|
||||
size_bucket = get_object_stream_size_bucket(*this.expected),
|
||||
expected = *this.expected,
|
||||
emitted = *this.emitted,
|
||||
remaining = *this.remaining,
|
||||
strategy = this.strategy,
|
||||
buffer_source = this.buffer_source,
|
||||
state = "reader_stream_short_eof",
|
||||
error = %err,
|
||||
"GetObject ReaderStream ended before expected length"
|
||||
"GetObject reader stream ended before the committed content length"
|
||||
);
|
||||
Poll::Ready(Some(Err(Box::new(err) as S3StdError)))
|
||||
}
|
||||
@@ -1590,10 +1631,17 @@ where
|
||||
*this.emitted,
|
||||
*this.remaining,
|
||||
);
|
||||
// Deliberately not logged at warn here: every production body
|
||||
// wraps a GetObjectStreamingReader, and that layer already
|
||||
// reports this same error once with `state = "read_failed"` and
|
||||
// the object identity. A second unconditional line per failed
|
||||
// GET would read as two distinct faults. The chunk-debug build
|
||||
// still gets this layer's view of the same error.
|
||||
#[cfg(feature = "tracing-chunk-debug")]
|
||||
tracing::error!(
|
||||
emitted = *this.emitted,
|
||||
expected = *this.expected,
|
||||
error_class = error_class,
|
||||
error = %err,
|
||||
"GetObject ReaderStream returned error"
|
||||
);
|
||||
@@ -1646,8 +1694,12 @@ where
|
||||
|
||||
struct GetObjectStreamingReader<R> {
|
||||
inner: Option<R>,
|
||||
// request_id + optional content_range are only used for diagnostic correlation and
|
||||
// failure bucketing; they do not alter stream behavior.
|
||||
// bucket/object + request_id + optional content_range are only used for diagnostic
|
||||
// correlation and failure bucketing; they do not alter stream behavior. The object
|
||||
// identity is what turns a mid-stream failure into an actionable report: a request_id
|
||||
// alone cannot tell an operator which object reads short (issue #4784).
|
||||
bucket: String,
|
||||
object: String,
|
||||
request_id: String,
|
||||
content_range: Option<String>,
|
||||
expected: usize,
|
||||
@@ -1666,8 +1718,8 @@ impl<R> GetObjectStreamingReader<R> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
inner: R,
|
||||
_bucket: &str,
|
||||
_key: &str,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
request_id: &str,
|
||||
content_range: Option<String>,
|
||||
expected: usize,
|
||||
@@ -1677,6 +1729,8 @@ impl<R> GetObjectStreamingReader<R> {
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Some(inner),
|
||||
bucket: bucket.to_string(),
|
||||
object: key.to_string(),
|
||||
request_id: request_id.to_string(),
|
||||
content_range,
|
||||
expected,
|
||||
@@ -1817,6 +1871,8 @@ impl<R> GetObjectStreamingReader<R> {
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -1853,6 +1909,8 @@ impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -1871,10 +1929,12 @@ impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
||||
self.timer = None;
|
||||
let failure_reason = Self::classify_read_error(&error);
|
||||
self.finish_err();
|
||||
warn!(
|
||||
error!(
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -1916,6 +1976,8 @@ impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -1956,10 +2018,12 @@ impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
||||
self.begin_resume(error);
|
||||
continue;
|
||||
}
|
||||
warn!(
|
||||
error!(
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -1990,10 +2054,12 @@ impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
||||
let failure_reason = Self::classify_read_error(&err);
|
||||
self.timer = None;
|
||||
self.finish_err();
|
||||
warn!(
|
||||
error!(
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -2029,6 +2095,8 @@ impl<R> Drop for GetObjectStreamingReader<R> {
|
||||
event = EVENT_GET_OBJECT_STREAM_BODY,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
bucket = %self.bucket,
|
||||
object = %self.object,
|
||||
request_id = %self.request_id,
|
||||
range = %self.content_range.as_deref().unwrap_or("full"),
|
||||
size_bucket = get_object_stream_size_bucket(self.expected),
|
||||
@@ -4303,7 +4371,8 @@ impl DefaultObjectUsecase {
|
||||
lifecycle,
|
||||
resume,
|
||||
);
|
||||
let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source);
|
||||
let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source)
|
||||
.with_diagnostics(bucket, key, request_id);
|
||||
let blob = StreamingBlob::new(stream);
|
||||
if let Some(handoff_start) = handoff_start {
|
||||
rustfs_io_metrics::record_get_object_response_handoff(
|
||||
@@ -16326,7 +16395,12 @@ mod tests {
|
||||
assert_eq!(body, vec![b'a'; 65]);
|
||||
}
|
||||
|
||||
// Serial with the capture test below: both drive the same short-EOF log
|
||||
// callsite, and `tracing` caches callsite interest process-wide. Running
|
||||
// this one concurrently on a thread with no subscriber re-caches that
|
||||
// callsite as "never interested" and blinds the capture.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn get_object_reader_stream_errors_on_short_eof() {
|
||||
let stream = GetObjectReaderStream::new(
|
||||
std::io::Cursor::new(b"he".to_vec()),
|
||||
@@ -16349,6 +16423,134 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Collects the structured fields of every event emitted while installed,
|
||||
/// so a test can assert what an operator would actually read in the log
|
||||
/// rather than only that an error value was returned.
|
||||
type CapturedFieldMap = std::collections::HashMap<String, String>;
|
||||
type CapturedEventLog = Arc<Mutex<Vec<CapturedFieldMap>>>;
|
||||
|
||||
struct CapturedEvents(CapturedEventLog);
|
||||
|
||||
struct CapturedFields(CapturedFieldMap);
|
||||
|
||||
impl tracing::field::Visit for CapturedFields {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name().to_string(), format!("{value:?}"));
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.0.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||
let mut fields = CapturedFields(CapturedFieldMap::new());
|
||||
event.record(&mut fields);
|
||||
self.0.lock().expect("captured events should not poison").push(fields.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) {
|
||||
use tracing_subscriber::{Registry, prelude::*};
|
||||
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured)));
|
||||
let guard = tracing::subscriber::set_default(subscriber);
|
||||
// `tracing` caches per-callsite interest process-wide, so a subscriber
|
||||
// installed by a test running in parallel can leave the log sites below
|
||||
// cached as "never interested" and this capture would silently see
|
||||
// nothing. Force the callsites to re-ask the subscriber we just
|
||||
// installed.
|
||||
tracing::callsite::rebuild_interest_cache();
|
||||
(captured, guard)
|
||||
}
|
||||
|
||||
fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap {
|
||||
let events = captured.lock().expect("captured events should not poison");
|
||||
events
|
||||
.iter()
|
||||
.find(|fields| fields.get("state").is_some_and(|value| value == state))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"a `{state}` streaming body failure must be logged, not only counted in a metric. \
|
||||
Captured {} event(s): {:?}",
|
||||
events.len(),
|
||||
events
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// rustfs#4784: a GET body that ends short of its committed Content-Length
|
||||
/// is the fault that breaks every downstream copier (replication, site
|
||||
/// replication, `rclone sync`), yet this layer only fed a metric counter —
|
||||
/// its log line was compiled out unless the `tracing-chunk-debug` feature
|
||||
/// was on, so operators saw nothing on the source side.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn get_object_reader_stream_short_eof_names_the_object() {
|
||||
let (captured, _guard) = capture_events();
|
||||
|
||||
let stream = GetObjectReaderStream::new(
|
||||
std::io::Cursor::new(b"he".to_vec()),
|
||||
64,
|
||||
5,
|
||||
GetObjectStreamStrategy::Standard.as_str(),
|
||||
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
||||
)
|
||||
.with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof");
|
||||
|
||||
stream
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect_err("short reader should fail the streaming body");
|
||||
|
||||
let event = find_stream_body_event(&captured, "reader_stream_short_eof");
|
||||
assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless"));
|
||||
assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90"));
|
||||
assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof"));
|
||||
assert_eq!(event.get("expected").map(String::as_str), Some("5"));
|
||||
assert_eq!(event.get("emitted").map(String::as_str), Some("2"));
|
||||
assert_eq!(event.get("remaining").map(String::as_str), Some("3"));
|
||||
}
|
||||
|
||||
/// The inner reader already logged mid-stream failures, but only under a
|
||||
/// request_id — which cannot be resolved back to an object once the request
|
||||
/// is gone. Without the identity the report in #4784 was unactionable.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn get_object_streaming_reader_short_eof_names_the_object() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let (captured, _guard) = capture_events();
|
||||
|
||||
let mut reader = GetObjectStreamingReader::new(
|
||||
std::io::Cursor::new(b"short".to_vec()),
|
||||
"restic-paperless",
|
||||
"index/41b5a4c2344edb90",
|
||||
"req-streaming-short-eof",
|
||||
None,
|
||||
10,
|
||||
Duration::ZERO,
|
||||
GetObjectBodyLifecycle::tracked(GetObjectGuard::new()),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("short body under a larger Content-Length must fail the stream");
|
||||
|
||||
let event = find_stream_body_event(&captured, "short_eof");
|
||||
assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless"));
|
||||
assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90"));
|
||||
assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_object_stream_failure_labels_are_low_cardinality() {
|
||||
assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF);
|
||||
|
||||
@@ -2210,6 +2210,89 @@ mod tests_policy {
|
||||
assert!(!policy.is_allowed(&args_fail).await, "IAM Policy should deny non-matching IP");
|
||||
}
|
||||
|
||||
/// The failure this issue is about: when `remote_addr` is dropped the
|
||||
/// `aws:SourceIp` key never reaches the condition map, and `AddrFunc::evaluate`
|
||||
/// returns `false` for an absent key. That flips two policy shapes in
|
||||
/// opposite directions, and only one of them looks like a failure
|
||||
/// (rustfs/backlog#1885).
|
||||
#[tokio::test]
|
||||
async fn source_ip_policies_break_in_both_directions_when_the_key_is_missing() {
|
||||
let allow_from_office = |effect: &str| {
|
||||
format!(
|
||||
r#"{{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{{"Effect": "Allow", "Action": ["admin:ConfigUpdate"], "Resource": ["arn:aws:s3:::*"]}},
|
||||
{{
|
||||
"Effect": "{effect}",
|
||||
"Action": ["admin:ConfigUpdate"],
|
||||
"Resource": ["arn:aws:s3:::*"],
|
||||
"Condition": {{"IpAddress": {{"aws:SourceIp": "192.168.1.0/24"}}}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
)
|
||||
};
|
||||
|
||||
let claims = HashMap::new();
|
||||
let groups = None;
|
||||
let mut with_ip = HashMap::new();
|
||||
with_ip.insert("SourceIp".to_string(), vec!["192.168.1.10".to_string()]);
|
||||
let without_ip: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
let args_with_ip = Args {
|
||||
account: "test-account",
|
||||
groups: &groups,
|
||||
action: Action::AdminAction(rustfs_policy::policy::action::AdminAction::ConfigUpdateAdminAction),
|
||||
bucket: "",
|
||||
conditions: &with_ip,
|
||||
is_owner: false,
|
||||
object: "",
|
||||
claims: &claims,
|
||||
deny_only: false,
|
||||
};
|
||||
let args_without_ip = Args {
|
||||
conditions: &without_ip,
|
||||
..args_with_ip
|
||||
};
|
||||
|
||||
// Deny + blacklist: the bypass shape. With the key present the deny
|
||||
// matches and the request is refused; drop the key and the deny stops
|
||||
// matching, so a source that policy means to block gets through.
|
||||
let deny_policy: Policy = serde_json::from_str(&allow_from_office("Deny")).expect("deny policy parses");
|
||||
assert!(
|
||||
!deny_policy.is_allowed(&args_with_ip).await,
|
||||
"a blacklisted source must be refused while aws:SourceIp is present"
|
||||
);
|
||||
assert!(
|
||||
deny_policy.is_allowed(&args_without_ip).await,
|
||||
"dropping remote_addr makes the Deny statement unreachable — this is the bypass"
|
||||
);
|
||||
|
||||
// Allow + whitelist: the availability shape, and the only one an
|
||||
// operator would notice, which is why the bypass above went unseen.
|
||||
let allow_policy: Policy = serde_json::from_str(
|
||||
r#"{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["admin:ConfigUpdate"],
|
||||
"Resource": ["arn:aws:s3:::*"],
|
||||
"Condition": {"IpAddress": {"aws:SourceIp": "192.168.1.0/24"}}
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.expect("allow policy parses");
|
||||
assert!(
|
||||
allow_policy.is_allowed(&args_with_ip).await,
|
||||
"a whitelisted source must be allowed while aws:SourceIp is present"
|
||||
);
|
||||
assert!(
|
||||
!allow_policy.is_allowed(&args_without_ip).await,
|
||||
"dropping remote_addr locks out a legitimate admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bucket_policy_source_ip() {
|
||||
let policy_json = r#"{
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Device key, certificate request, and registration proof of possession.
|
||||
//!
|
||||
//! The transcript and signature rules implemented here are frozen by
|
||||
//! `protocol/agent/v1/registration-proof.md` and by the golden fixtures under
|
||||
//! `protocol/agent/v1/fixtures/registration/`. Connect verifies what this
|
||||
//! module produces, so any divergence is a protocol break rather than a
|
||||
//! local behaviour change.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD as BASE64_URL_NO_PAD};
|
||||
use p256::ecdsa::signature::Signer as _;
|
||||
use p256::ecdsa::{Signature, SigningKey};
|
||||
use p256::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// The 30 US-ASCII octets that open every registration transcript. Case is
|
||||
/// significant: a lowercase spelling is a different transcript, and the
|
||||
/// protocol publishes it as a reject vector so the two can never be confused.
|
||||
const REGISTRATION_DOMAIN: &[u8] = b"RUSTFS-CONNECT-REGISTRATION-V1";
|
||||
|
||||
/// Separator between a field's decimal octet length and its value.
|
||||
const FIELD_SEPARATOR: u8 = b':';
|
||||
|
||||
/// Terminator after the domain and after every field value, including the last.
|
||||
const FIELD_TERMINATOR: u8 = b'\n';
|
||||
|
||||
/// The transcript binds exactly seven fields, always present, always in order.
|
||||
const FIELD_COUNT: usize = 7;
|
||||
|
||||
/// The one algorithm this surface accepts. The enumeration is closed: an
|
||||
/// unrecognised value is refused rather than discarded.
|
||||
pub const PROOF_ALGORITHM: &str = "ES256";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IdentityError {
|
||||
/// A transcript field carried an octet the encoding cannot represent
|
||||
/// unambiguously. The transcript is length-prefixed, so a newline inside a
|
||||
/// value would still parse; it is refused because a caller that can place
|
||||
/// one can shift the boundary a verifier reconstructs from its own row.
|
||||
#[error("registration transcript field {field} is not printable US-ASCII without a line feed")]
|
||||
UnencodableField { field: &'static str },
|
||||
|
||||
/// An expiry that predates the epoch cannot be spelled without a sign, and
|
||||
/// the length rule admits no sign.
|
||||
#[error("registration token expiry {expires_unix} is negative")]
|
||||
NegativeExpiry { expires_unix: i64 },
|
||||
|
||||
#[error("device key is not a valid P-256 private key: {0}")]
|
||||
MalformedKey(String),
|
||||
|
||||
#[error("failed to generate the device certificate request: {0}")]
|
||||
CertificateRequest(String),
|
||||
}
|
||||
|
||||
/// The canonical byte sequence a device signs, and its digest.
|
||||
///
|
||||
/// Built, never parsed: nothing reads a transcript back, so there is no such
|
||||
/// thing as a malformed one once it has been constructed.
|
||||
#[derive(Clone)]
|
||||
pub struct RegistrationTranscript {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RegistrationTranscript {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// The transcript embeds the challenge nonce, which is disclosed to an
|
||||
// operator exactly once beside the token secret and is deliberately
|
||||
// never republished. Rendering the octets would put it into any log or
|
||||
// panic message that formats a transcript, so only the length and the
|
||||
// digest — both already public in the fixtures — are shown.
|
||||
f.debug_struct("RegistrationTranscript")
|
||||
.field("len", &self.bytes.len())
|
||||
.field("sha256", &self.sha256_hex())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistrationTranscript {
|
||||
/// Assemble the transcript from the seven bound values.
|
||||
///
|
||||
/// Five of them reach the device out of band with the token secret and are
|
||||
/// never sent back, which is what stops a device choosing its own
|
||||
/// transcript. They cross an operator-supplied boundary, so each one is
|
||||
/// checked here rather than trusted.
|
||||
pub fn build(
|
||||
registration_token_uid: &str,
|
||||
organization_uid: &str,
|
||||
cluster_uid: &str,
|
||||
request_id: &str,
|
||||
challenge_nonce: &str,
|
||||
expires_unix: i64,
|
||||
certificate_request: &[u8],
|
||||
) -> Result<Self, IdentityError> {
|
||||
if expires_unix < 0 {
|
||||
return Err(IdentityError::NegativeExpiry { expires_unix });
|
||||
}
|
||||
|
||||
let expiry = expires_unix.to_string();
|
||||
let csr_digest = BASE64_URL_NO_PAD.encode(Sha256::digest(certificate_request));
|
||||
|
||||
let fields: [(&'static str, &str); FIELD_COUNT] = [
|
||||
("registrationTokenUid", registration_token_uid),
|
||||
("organizationUid", organization_uid),
|
||||
("clusterUid", cluster_uid),
|
||||
("requestId", request_id),
|
||||
("challengeNonce", challenge_nonce),
|
||||
("expiresUnix", &expiry),
|
||||
("certificateRequestSha256", &csr_digest),
|
||||
];
|
||||
|
||||
let mut bytes = Vec::with_capacity(REGISTRATION_DOMAIN.len() + 1 + 320);
|
||||
bytes.extend_from_slice(REGISTRATION_DOMAIN);
|
||||
bytes.push(FIELD_TERMINATOR);
|
||||
|
||||
for (name, value) in fields {
|
||||
if !value.is_ascii() || value.as_bytes().contains(&FIELD_TERMINATOR) {
|
||||
return Err(IdentityError::UnencodableField { field: name });
|
||||
}
|
||||
// The length is the octet count, and `is_ascii` above makes octets
|
||||
// and characters the same count for these values.
|
||||
bytes.extend_from_slice(value.len().to_string().as_bytes());
|
||||
bytes.push(FIELD_SEPARATOR);
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
bytes.push(FIELD_TERMINATOR);
|
||||
}
|
||||
|
||||
Ok(Self { bytes })
|
||||
}
|
||||
|
||||
/// The exact octets that are signed.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
|
||||
/// SHA-256 over the transcript, as lowercase hex. Published beside the
|
||||
/// canonical string in `transcript.json` so a producer can prove its
|
||||
/// builder without performing any cryptography.
|
||||
pub fn sha256_hex(&self) -> String {
|
||||
let digest = Sha256::digest(&self.bytes);
|
||||
digest.iter().fold(String::with_capacity(64), |mut out, byte| {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "{byte:02x}");
|
||||
out
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A proof of possession, in the shape the exchange body carries.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RegistrationProof {
|
||||
pub algorithm: String,
|
||||
/// 86 base64url characters, unpadded, decoding to a fixed-width 64 octet
|
||||
/// `r || s`.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A device's P-256 key and the operations that key authorises.
|
||||
///
|
||||
/// The private key never leaves this type: it is not exposed by a getter, not
|
||||
/// rendered by `Debug`, and not written anywhere except the sealed store.
|
||||
pub struct DeviceIdentity {
|
||||
signing_key: SigningKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DeviceIdentity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// A device identity is a private key. Rendering any part of it, even a
|
||||
// fingerprint, puts key-derived material into logs and support bundles.
|
||||
f.write_str("DeviceIdentity(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceIdentity {
|
||||
/// Generate a fresh P-256 key.
|
||||
pub fn generate() -> Self {
|
||||
// p256 is pinned to rand_core 0.6 while the workspace `rand` is 0.10, so
|
||||
// the RNG comes from p256's own re-export rather than the workspace one.
|
||||
Self {
|
||||
signing_key: SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a key from its PKCS#8 DER encoding. A key that does not decode is
|
||||
/// an error rather than a reason to mint a replacement: silently
|
||||
/// regenerating would strand the certificate already issued for the old one.
|
||||
pub fn from_pkcs8_der(der: &[u8]) -> Result<Self, IdentityError> {
|
||||
SigningKey::from_pkcs8_der(der)
|
||||
.map(|signing_key| Self { signing_key })
|
||||
.map_err(|error| IdentityError::MalformedKey(error.to_string()))
|
||||
}
|
||||
|
||||
/// Serialise the key for the sealed store. The result is wrapped so it is
|
||||
/// wiped when the caller drops it.
|
||||
pub fn to_pkcs8_der(&self) -> Result<Zeroizing<Vec<u8>>, IdentityError> {
|
||||
self.signing_key
|
||||
.to_pkcs8_der()
|
||||
.map(|der| Zeroizing::new(der.as_bytes().to_vec()))
|
||||
.map_err(|error| IdentityError::MalformedKey(error.to_string()))
|
||||
}
|
||||
|
||||
/// Build the PKCS#10 certificate request Connect consumes.
|
||||
///
|
||||
/// Connect reads the request for its SubjectPublicKeyInfo and its
|
||||
/// self-signature and for nothing else: it assigns the device uid itself,
|
||||
/// so the subject and SAN carried here name nothing Connect will honour.
|
||||
pub fn certificate_request_der(&self) -> Result<Vec<u8>, IdentityError> {
|
||||
let pkcs8 = self.to_pkcs8_der()?;
|
||||
let key_pair =
|
||||
rcgen::KeyPair::try_from(pkcs8.as_slice()).map_err(|error| IdentityError::CertificateRequest(error.to_string()))?;
|
||||
|
||||
let params = rcgen::CertificateParams::default();
|
||||
let request = params
|
||||
.serialize_request(&key_pair)
|
||||
.map_err(|error| IdentityError::CertificateRequest(error.to_string()))?;
|
||||
|
||||
Ok(request.der().to_vec())
|
||||
}
|
||||
|
||||
/// Standard padded base64 of the certificate request, as the body carries it.
|
||||
pub fn certificate_request_base64(&self) -> Result<String, IdentityError> {
|
||||
Ok(BASE64_STANDARD.encode(self.certificate_request_der()?))
|
||||
}
|
||||
|
||||
/// Sign a transcript, producing the low-S fixed-width proof.
|
||||
///
|
||||
/// ECDSA admits two valid spellings of every signature, and a proof with
|
||||
/// two spellings is not an identity, so `s` is normalised into the lower
|
||||
/// half of the group order before encoding.
|
||||
pub fn sign_registration(&self, transcript: &RegistrationTranscript) -> RegistrationProof {
|
||||
let signature: Signature = self.signing_key.sign(transcript.as_bytes());
|
||||
let canonical = signature.normalize_s().unwrap_or(signature);
|
||||
|
||||
RegistrationProof {
|
||||
algorithm: PROOF_ALGORITHM.to_string(),
|
||||
value: BASE64_URL_NO_PAD.encode(canonical.to_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The device public key, DER SubjectPublicKeyInfo.
|
||||
pub fn public_key_der(&self) -> Vec<u8> {
|
||||
use p256::pkcs8::EncodePublicKey as _;
|
||||
|
||||
self.signing_key
|
||||
.verifying_key()
|
||||
.to_public_key_der()
|
||||
.expect("a P-256 verifying key always encodes as SubjectPublicKeyInfo")
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! On-disk home of the device key.
|
||||
//!
|
||||
//! A device that loses its key loses the certificate issued for it and has to
|
||||
//! spend a fresh registration token to get back, so the store is written
|
||||
//! durably and published exactly once. It deliberately does not reuse
|
||||
//! `rustfs_kms`'s `durable_file`, which implements the same commit protocol
|
||||
//! for envelope keys but is `pub(crate)` to that crate and carries KMS error
|
||||
//! and failpoint types this path has no use for.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::identity::{DeviceIdentity, IdentityError};
|
||||
|
||||
/// Name of the key file inside the store directory.
|
||||
const KEY_FILE: &str = "device.key";
|
||||
|
||||
/// Owner read/write only. The key is the device's whole identity.
|
||||
#[cfg(unix)]
|
||||
const KEY_MODE: u32 = 0o600;
|
||||
|
||||
/// Distinguishes the staging file of concurrent publishers. The process id
|
||||
/// alone is not enough: several threads of one process may initialise the same
|
||||
/// store, and a shared staging name would let them truncate each other's
|
||||
/// half-written key and then link the result into place.
|
||||
static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("connect identity store I/O failed at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
/// The key file exists but does not decode. Fail closed: regenerating here
|
||||
/// would silently abandon a device certificate that is still valid and
|
||||
/// still trusted by the control plane.
|
||||
#[error("connect device key at {path} is unreadable and was left untouched: {source}")]
|
||||
Corrupt {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: IdentityError,
|
||||
},
|
||||
|
||||
/// The key file is present with permissions that expose it. Refused rather
|
||||
/// than repaired, because a key that has been world-readable has to be
|
||||
/// treated as disclosed and rotated, not quietly re-sealed.
|
||||
#[cfg(unix)]
|
||||
#[error("connect device key at {path} has mode {mode:o}, expected {expected:o}")]
|
||||
Permissions { path: PathBuf, mode: u32, expected: u32 },
|
||||
|
||||
#[error(transparent)]
|
||||
Identity(#[from] IdentityError),
|
||||
}
|
||||
|
||||
/// A directory holding one device identity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IdentityStore {
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl IdentityStore {
|
||||
pub fn new(directory: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
directory: directory.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key_path(&self) -> PathBuf {
|
||||
self.directory.join(KEY_FILE)
|
||||
}
|
||||
|
||||
/// Return the stored identity, or `None` when this deployment has never
|
||||
/// been enrolled. Reading never creates anything, so an unconfigured
|
||||
/// server can ask without acquiring an identity as a side effect.
|
||||
pub fn load(&self) -> Result<Option<DeviceIdentity>, StoreError> {
|
||||
let path = self.key_path();
|
||||
|
||||
let der = match fs::read(&path) {
|
||||
Ok(der) => Zeroizing::new(der),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => return Err(StoreError::Io { path, source }),
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let metadata = fs::metadata(&path).map_err(|source| StoreError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let mode = metadata.permissions().mode() & 0o7777;
|
||||
if mode != KEY_MODE {
|
||||
return Err(StoreError::Permissions {
|
||||
path,
|
||||
mode,
|
||||
expected: KEY_MODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DeviceIdentity::from_pkcs8_der(&der)
|
||||
.map(Some)
|
||||
.map_err(|source| StoreError::Corrupt { path, source })
|
||||
}
|
||||
|
||||
/// Return the stored identity, generating and publishing one the first
|
||||
/// time. Concurrent callers converge on a single identity: publication is
|
||||
/// a no-clobber link, and whoever loses the race discards its candidate
|
||||
/// and reads the winner's.
|
||||
pub fn load_or_create(&self) -> Result<DeviceIdentity, StoreError> {
|
||||
if let Some(identity) = self.load()? {
|
||||
return Ok(identity);
|
||||
}
|
||||
|
||||
fs::create_dir_all(&self.directory).map_err(|source| StoreError::Io {
|
||||
path: self.directory.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let candidate = DeviceIdentity::generate();
|
||||
let der = candidate.to_pkcs8_der()?;
|
||||
|
||||
match self.publish(&der) {
|
||||
Ok(()) => Ok(candidate),
|
||||
// Another process published first. Its key is the identity; ours
|
||||
// was never written anywhere and simply goes out of scope.
|
||||
Err(StoreError::Io { source, .. }) if source.kind() == io::ErrorKind::AlreadyExists => {
|
||||
self.load()?.ok_or_else(|| StoreError::Io {
|
||||
path: self.key_path(),
|
||||
source: io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"device key vanished immediately after another writer published it",
|
||||
),
|
||||
})
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write, seal, fsync, then link into place and fsync the directory. The
|
||||
/// key is durable before it is reachable, and it is reachable only once.
|
||||
fn publish(&self, der: &[u8]) -> Result<(), StoreError> {
|
||||
use std::io::Write as _;
|
||||
|
||||
let final_path = self.key_path();
|
||||
let temp_path = self.directory.join(format!(
|
||||
"{KEY_FILE}.{}.{}.tmp",
|
||||
std::process::id(),
|
||||
STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
|
||||
let io_at = |path: &Path| {
|
||||
let path = path.to_path_buf();
|
||||
move |source| StoreError::Io { path, source }
|
||||
};
|
||||
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(KEY_MODE);
|
||||
}
|
||||
|
||||
let mut file = options.open(&temp_path).map_err(io_at(&temp_path))?;
|
||||
|
||||
let result = (|| -> Result<(), StoreError> {
|
||||
file.write_all(der).map_err(io_at(&temp_path))?;
|
||||
|
||||
// The umask can only narrow the creation mode, so set and verify
|
||||
// the exact mode before the bytes become durable.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
file.set_permissions(fs::Permissions::from_mode(KEY_MODE))
|
||||
.map_err(io_at(&temp_path))?;
|
||||
let mode = file.metadata().map_err(io_at(&temp_path))?.permissions().mode() & 0o7777;
|
||||
if mode != KEY_MODE {
|
||||
return Err(StoreError::Permissions {
|
||||
path: temp_path.clone(),
|
||||
mode,
|
||||
expected: KEY_MODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(io_at(&temp_path))?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
drop(file);
|
||||
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// `hard_link` fails rather than replacing an existing key, which is
|
||||
// what makes a retry return the original identity instead of minting
|
||||
// a second one.
|
||||
let published = fs::hard_link(&temp_path, &final_path);
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
published.map_err(io_at(&final_path))?;
|
||||
|
||||
fsync_dir(&self.directory).map_err(io_at(&self.directory))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fsync a directory so a freshly linked entry survives power loss. Directories
|
||||
/// cannot be opened for syncing on Windows, where this is a no-op.
|
||||
fn fsync_dir(dir: &Path) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::File::open(dir)?.sync_all()?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = dir;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! RustFS Connect device identity.
|
||||
//!
|
||||
//! A cluster device proves possession of its own key when it exchanges a
|
||||
//! one-time registration token for a durable certificate. This module owns the
|
||||
//! device-side half of that exchange: the P-256 key, the PKCS#10 certificate
|
||||
//! request built from it, and the proof-of-possession signature over the
|
||||
//! canonical transcript frozen by
|
||||
//! `protocol/agent/v1/registration-proof.md`.
|
||||
//!
|
||||
//! Nothing here contacts the network or starts a task. A deployment that has
|
||||
//! not been enrolled into a Connect control plane never calls into it, so an
|
||||
//! unconfigured server generates no key and holds no identity.
|
||||
|
||||
pub mod identity;
|
||||
pub mod identity_store;
|
||||
|
||||
pub use identity::{DeviceIdentity, IdentityError, RegistrationProof, RegistrationTranscript};
|
||||
pub use identity_store::{IdentityStore, StoreError};
|
||||
@@ -80,7 +80,6 @@ pub(crate) mod bitrot_selftest;
|
||||
pub mod capacity;
|
||||
pub mod cluster_snapshot;
|
||||
pub mod config;
|
||||
pub mod connect;
|
||||
pub mod delete_tail_activity;
|
||||
pub mod diagnose;
|
||||
pub mod embedded;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Conformance of this repository's copy of the Connect agent protocol fixtures.
|
||||
//!
|
||||
//! `fixture-sets.json` requires a byte-identical copy of every populated set,
|
||||
//! and Connect's `make protocol-compat` runs this test by name (the Makefile's
|
||||
//! `RUSTFS_CONSUMER_TESTS` default) after comparing the two trees. The
|
||||
//! comparison there proves the copies match; this proves the copy is internally
|
||||
//! consistent, so a fixture edited on this side is caught here even when
|
||||
//! Connect is not checked out.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures")
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The registry is closed at eight sets; a ninth is a protocol change, not a
|
||||
/// fixture change. Mirrors `EXPECTED_SETS` in Connect's checker.
|
||||
const EXPECTED_SETS: [&str; 8] = [
|
||||
"auth",
|
||||
"version",
|
||||
"registration",
|
||||
"heartbeat",
|
||||
"inventory",
|
||||
"offline-enrollment",
|
||||
"bundle",
|
||||
"redaction",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn agent_protocol_fixtures_registry_is_the_frozen_eight_sets() {
|
||||
let registry: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(fixture_root().join("fixture-sets.json")).expect("read fixture-sets.json"))
|
||||
.expect("fixture-sets.json parses");
|
||||
|
||||
let names: Vec<&str> = registry["sets"]
|
||||
.as_array()
|
||||
.expect("sets is an array")
|
||||
.iter()
|
||||
.map(|set| set["name"].as_str().expect("set has a name"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, EXPECTED_SETS, "the fixture registry must stay closed and ordered");
|
||||
assert_eq!(
|
||||
registry["consumerCopy"]["path"].as_str(),
|
||||
Some("protocol/agent/v1/fixtures"),
|
||||
"this copy lives at the path the registry declares"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_protocol_fixtures_match_their_manifests() {
|
||||
let root = fixture_root();
|
||||
let registry: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("fixture-sets.json")).expect("read fixture-sets.json"))
|
||||
.expect("fixture-sets.json parses");
|
||||
|
||||
let mut checked = 0usize;
|
||||
|
||||
for set in registry["sets"].as_array().expect("sets is an array") {
|
||||
let name = set["name"].as_str().expect("set has a name");
|
||||
let status = set["status"].as_str().expect("set has a status");
|
||||
|
||||
let set_dir = root.join(name);
|
||||
if status == "reserved" {
|
||||
assert!(!set_dir.exists(), "reserved fixture set '{name}' must hold no files yet");
|
||||
continue;
|
||||
}
|
||||
|
||||
let manifest = fs::read_to_string(set_dir.join("MANIFEST.sha256"))
|
||||
.unwrap_or_else(|error| panic!("populated set '{name}' must carry a manifest: {error}"));
|
||||
|
||||
let mut listed = Vec::new();
|
||||
for line in manifest.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let (digest, file) = line
|
||||
.split_once(" ")
|
||||
.unwrap_or_else(|| panic!("malformed manifest line in '{name}': {line}"));
|
||||
listed.push(file.to_string());
|
||||
|
||||
let bytes = fs::read(set_dir.join(file))
|
||||
.unwrap_or_else(|error| panic!("set '{name}' lists {file} which is missing: {error}"));
|
||||
assert_eq!(sha256_hex(&bytes), digest, "set '{name}' file {file} does not match its manifest");
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
// A file present but unlisted would travel unchecked, so the manifest
|
||||
// has to be exhaustive rather than merely correct about what it names.
|
||||
let mut present: Vec<String> = fs::read_dir(&set_dir)
|
||||
.expect("read fixture set directory")
|
||||
.map(|entry| entry.expect("read dir entry").file_name().to_string_lossy().into_owned())
|
||||
.filter(|file| file != "MANIFEST.sha256")
|
||||
.collect();
|
||||
present.sort();
|
||||
listed.sort();
|
||||
assert_eq!(present, listed, "set '{name}' holds files its manifest does not list");
|
||||
}
|
||||
|
||||
assert!(checked > 0, "no fixture files were verified");
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Connect device identity: transcript conformance, key durability, and the
|
||||
//! properties the registration exchange depends on.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_NO_PAD;
|
||||
use rustfs::connect::identity::{DeviceIdentity, IdentityError, RegistrationTranscript};
|
||||
use rustfs::connect::identity_store::{IdentityStore, StoreError};
|
||||
|
||||
fn transcript_fixture() -> serde_json::Value {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/registration/transcript.json");
|
||||
serde_json::from_slice(&fs::read(path).expect("read transcript.json")).expect("transcript.json parses")
|
||||
}
|
||||
|
||||
fn accept_vectors() -> serde_json::Value {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/registration/accept-vectors.json");
|
||||
serde_json::from_slice(&fs::read(path).expect("read accept-vectors.json")).expect("accept-vectors.json parses")
|
||||
}
|
||||
|
||||
/// Extract the SubjectPublicKeyInfo from a PKCS#10 request.
|
||||
///
|
||||
/// The protocol freezes the DER prefix of a P-256 SubjectPublicKeyInfo, and the
|
||||
/// key that follows it is a 65 octet uncompressed point, so the whole structure
|
||||
/// is a fixed 91 octets located by its prefix. This is a test reading a fixture,
|
||||
/// not a parser: Connect owns certificate request parsing.
|
||||
fn subject_public_key_info(csr_der: &[u8]) -> Vec<u8> {
|
||||
let prefix = hex_to_bytes("3059301306072a8648ce3d020106082a8648ce3d030107034200");
|
||||
let start = csr_der
|
||||
.windows(prefix.len())
|
||||
.position(|window| window == prefix)
|
||||
.expect("certificate request carries a P-256 SubjectPublicKeyInfo");
|
||||
csr_der[start..start + prefix.len() + 65].to_vec()
|
||||
}
|
||||
|
||||
/// Rebuild each accept vector's transcript from the values a verifier holds.
|
||||
///
|
||||
/// This is the interoperability assertion the protocol asks a producer to make:
|
||||
/// the five hidden fields come from the token row, the two visible ones from the
|
||||
/// request, and the result must equal the transcript Connect published.
|
||||
#[test]
|
||||
fn transcript_reproduces_every_accept_vector() {
|
||||
let vectors = accept_vectors();
|
||||
let list = vectors["vectors"].as_array().expect("accept vectors are a list");
|
||||
assert!(!list.is_empty(), "the accept vector set must not be empty");
|
||||
|
||||
for vector in list {
|
||||
let name = vector["name"].as_str().unwrap_or("<unnamed>");
|
||||
let token = &vector["tokenRecord"];
|
||||
let request = &vector["request"];
|
||||
|
||||
let csr = base64::engine::general_purpose::STANDARD
|
||||
.decode(
|
||||
request["certificateRequest"]
|
||||
.as_str()
|
||||
.expect("vector carries a certificate request"),
|
||||
)
|
||||
.expect("certificate request is base64");
|
||||
|
||||
let transcript = RegistrationTranscript::build(
|
||||
token["registrationTokenUid"].as_str().unwrap(),
|
||||
token["organizationUid"].as_str().unwrap(),
|
||||
token["clusterUid"].as_str().unwrap(),
|
||||
request["requestId"].as_str().unwrap(),
|
||||
token["challengeNonce"].as_str().unwrap(),
|
||||
token["expiresUnix"].as_i64().unwrap(),
|
||||
&csr,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("vector '{name}' must build: {error}"));
|
||||
|
||||
assert_eq!(
|
||||
transcript.as_bytes(),
|
||||
vector["serverTranscript"].as_str().unwrap().as_bytes(),
|
||||
"vector '{name}' transcript must match octet for octet"
|
||||
);
|
||||
assert_eq!(
|
||||
transcript.sha256_hex(),
|
||||
vector["serverTranscriptSha256"].as_str().unwrap(),
|
||||
"vector '{name}' transcript digest must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The published proofs were produced by the Connect-side implementation over
|
||||
/// keys this repository does not hold. Verifying them against a transcript this
|
||||
/// module rebuilt is the strongest available statement that the two
|
||||
/// implementations agree: a single wrong octet anywhere in the transcript makes
|
||||
/// real ECDSA verification fail.
|
||||
#[test]
|
||||
fn published_proofs_verify_over_locally_rebuilt_transcripts() {
|
||||
use p256::ecdsa::signature::Verifier as _;
|
||||
|
||||
let vectors = accept_vectors();
|
||||
let mut verified = 0usize;
|
||||
|
||||
for vector in vectors["vectors"].as_array().expect("accept vectors are a list") {
|
||||
let name = vector["name"].as_str().unwrap_or("<unnamed>");
|
||||
if vector["expected"]["verifiesMathematically"].as_bool() != Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let token = &vector["tokenRecord"];
|
||||
let request = &vector["request"];
|
||||
let csr = base64::engine::general_purpose::STANDARD
|
||||
.decode(request["certificateRequest"].as_str().unwrap())
|
||||
.expect("certificate request is base64");
|
||||
|
||||
let transcript = RegistrationTranscript::build(
|
||||
token["registrationTokenUid"].as_str().unwrap(),
|
||||
token["organizationUid"].as_str().unwrap(),
|
||||
token["clusterUid"].as_str().unwrap(),
|
||||
request["requestId"].as_str().unwrap(),
|
||||
token["challengeNonce"].as_str().unwrap(),
|
||||
token["expiresUnix"].as_i64().unwrap(),
|
||||
&csr,
|
||||
)
|
||||
.expect("transcript builds");
|
||||
|
||||
let raw = BASE64_URL_NO_PAD
|
||||
.decode(request["proof"]["value"].as_str().expect("vector carries a proof"))
|
||||
.expect("proof decodes");
|
||||
let signature = p256::ecdsa::Signature::from_slice(&raw).expect("signature parses");
|
||||
assert!(
|
||||
signature.normalize_s().is_none(),
|
||||
"vector '{name}' publishes a proof that is already low-S"
|
||||
);
|
||||
|
||||
let verifying =
|
||||
<p256::ecdsa::VerifyingKey as p256::pkcs8::DecodePublicKey>::from_public_key_der(&subject_public_key_info(&csr))
|
||||
.expect("public key decodes");
|
||||
|
||||
verifying
|
||||
.verify(transcript.as_bytes(), &signature)
|
||||
.unwrap_or_else(|error| panic!("vector '{name}' proof must verify over the rebuilt transcript: {error}"));
|
||||
verified += 1;
|
||||
}
|
||||
|
||||
assert!(verified > 0, "no accept vector was cross-verified");
|
||||
}
|
||||
|
||||
/// Drive the builder with the golden example's own inputs, using the accept
|
||||
/// vector whose certificate request produces the digest it publishes.
|
||||
fn transcript_from_fixture_inputs(csr_octets: &[u8]) -> Result<RegistrationTranscript, IdentityError> {
|
||||
let fixture = transcript_fixture();
|
||||
let inputs = &fixture["example"]["inputs"];
|
||||
|
||||
RegistrationTranscript::build(
|
||||
inputs["registrationTokenUid"].as_str().unwrap(),
|
||||
inputs["organizationUid"].as_str().unwrap(),
|
||||
inputs["clusterUid"].as_str().unwrap(),
|
||||
inputs["requestId"].as_str().unwrap(),
|
||||
inputs["challengeNonce"].as_str().unwrap(),
|
||||
inputs["expiresUnix"].as_i64().unwrap(),
|
||||
csr_octets,
|
||||
)
|
||||
}
|
||||
|
||||
fn csr_octets_matching_golden_digest() -> Vec<u8> {
|
||||
let want = transcript_fixture()["example"]["inputs"]["certificateRequestSha256"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
for vector in accept_vectors()["vectors"].as_array().expect("accept vectors are a list") {
|
||||
let Some(encoded) = vector["request"]["certificateRequest"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let der = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("certificate request is base64");
|
||||
let digest = BASE64_URL_NO_PAD.encode(<sha2::Sha256 as sha2::Digest>::digest(&der));
|
||||
if digest == want {
|
||||
return der;
|
||||
}
|
||||
}
|
||||
|
||||
panic!("no accept vector carries the certificate request the golden example digests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_reproduces_the_golden_example_byte_for_byte() {
|
||||
let fixture = transcript_fixture();
|
||||
let example = &fixture["example"];
|
||||
|
||||
let transcript = transcript_from_fixture_inputs(&csr_octets_matching_golden_digest()).expect("golden inputs build");
|
||||
|
||||
assert_eq!(
|
||||
transcript.as_bytes(),
|
||||
example["canonicalTranscript"].as_str().unwrap().as_bytes(),
|
||||
"the canonical transcript must match octet for octet"
|
||||
);
|
||||
assert_eq!(
|
||||
transcript.as_bytes().len() as u64,
|
||||
example["canonicalTranscriptLengthBytes"].as_u64().unwrap(),
|
||||
"the transcript length is frozen"
|
||||
);
|
||||
assert_eq!(
|
||||
transcript.sha256_hex(),
|
||||
example["canonicalTranscriptSha256"].as_str().unwrap(),
|
||||
"the transcript digest is frozen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_refuses_a_field_carrying_the_terminator() {
|
||||
// A newline inside a value would move the boundary a verifier rebuilds
|
||||
// from its own token row, which is the substitution the encoding exists to
|
||||
// prevent. Length-prefixing alone would still parse it.
|
||||
let error = RegistrationTranscript::build(
|
||||
"0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70\n36:evil",
|
||||
"0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
1_787_228_100,
|
||||
b"csr",
|
||||
)
|
||||
.expect_err("a field carrying 0x0a must be refused");
|
||||
|
||||
assert!(
|
||||
matches!(error, IdentityError::UnencodableField { field } if field == "organizationUid"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_refuses_a_non_ascii_field() {
|
||||
let error = RegistrationTranscript::build(
|
||||
"0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
// Multi-byte input would make the octet length and the character count
|
||||
// disagree, which is the exact confusion the length rule forbids.
|
||||
"a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0é",
|
||||
1_787_228_100,
|
||||
b"csr",
|
||||
)
|
||||
.expect_err("a non-ASCII field must be refused");
|
||||
|
||||
assert!(
|
||||
matches!(error, IdentityError::UnencodableField { field } if field == "challengeNonce"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_refuses_a_negative_expiry() {
|
||||
let error = transcript_negative_expiry().expect_err("a negative expiry has no unsigned spelling");
|
||||
assert!(
|
||||
matches!(error, IdentityError::NegativeExpiry { expires_unix: -1 }),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
fn transcript_negative_expiry() -> Result<RegistrationTranscript, IdentityError> {
|
||||
RegistrationTranscript::build(
|
||||
"0198f4b0-6f00-7b60-9271-7d8e9fa0b1c5",
|
||||
"0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70",
|
||||
"0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81",
|
||||
"3f2a1c94-5b6d-4e8f-9a0b-1c2d3e4f5a6b",
|
||||
"a3f1c07d9b2e4856af0c1d3b5e7f9012c4a6b8d0e2f4061738495a6b7c8d9e0f",
|
||||
-1,
|
||||
b"csr",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_is_a_canonical_low_s_signature_that_verifies() {
|
||||
use p256::ecdsa::signature::Verifier as _;
|
||||
|
||||
let identity = DeviceIdentity::generate();
|
||||
let csr = identity.certificate_request_der().expect("certificate request builds");
|
||||
let transcript = transcript_from_fixture_inputs(&csr).expect("transcript builds");
|
||||
|
||||
let proof = identity.sign_registration(&transcript);
|
||||
assert_eq!(proof.algorithm, "ES256");
|
||||
assert_eq!(proof.value.len(), 86, "the transfer encoding is 86 unpadded base64url characters");
|
||||
assert!(
|
||||
proof
|
||||
.value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
|
||||
"the proof must use the base64url alphabet with no padding"
|
||||
);
|
||||
|
||||
let raw = BASE64_URL_NO_PAD.decode(&proof.value).expect("proof decodes");
|
||||
assert_eq!(raw.len(), 64, "the signature is a fixed-width r || s");
|
||||
|
||||
let signature = p256::ecdsa::Signature::from_slice(&raw).expect("signature parses");
|
||||
assert!(
|
||||
signature.normalize_s().is_none(),
|
||||
"s must already be in the lower half of the group order"
|
||||
);
|
||||
|
||||
let spki = identity.public_key_der();
|
||||
let verifying =
|
||||
<p256::ecdsa::VerifyingKey as p256::pkcs8::DecodePublicKey>::from_public_key_der(&spki).expect("public key decodes");
|
||||
verifying
|
||||
.verify(transcript.as_bytes(), &signature)
|
||||
.expect("the proof must verify over the transcript octets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_does_not_verify_over_a_different_transcript() {
|
||||
use p256::ecdsa::signature::Verifier as _;
|
||||
|
||||
let identity = DeviceIdentity::generate();
|
||||
let csr = identity.certificate_request_der().expect("certificate request builds");
|
||||
let transcript = transcript_from_fixture_inputs(&csr).expect("transcript builds");
|
||||
let proof = identity.sign_registration(&transcript);
|
||||
|
||||
// A different certificate request is a different artifact and therefore a
|
||||
// different transcript; this is the proof-of-possession binding itself.
|
||||
let other = transcript_from_fixture_inputs(b"a different certificate request").expect("transcript builds");
|
||||
assert_ne!(transcript.as_bytes(), other.as_bytes());
|
||||
|
||||
let raw = BASE64_URL_NO_PAD.decode(&proof.value).expect("proof decodes");
|
||||
let signature = p256::ecdsa::Signature::from_slice(&raw).expect("signature parses");
|
||||
let verifying = <p256::ecdsa::VerifyingKey as p256::pkcs8::DecodePublicKey>::from_public_key_der(&identity.public_key_der())
|
||||
.expect("public key decodes");
|
||||
|
||||
assert!(
|
||||
verifying.verify(other.as_bytes(), &signature).is_err(),
|
||||
"a proof must not carry over to another transcript"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificate_request_presents_a_p256_key() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
let der = identity.certificate_request_der().expect("certificate request builds");
|
||||
|
||||
// The prefix the protocol freezes for a P-256 SubjectPublicKeyInfo. Its
|
||||
// presence proves the request carries the curve Connect requires.
|
||||
let spki_prefix = hex_to_bytes("3059301306072a8648ce3d020106082a8648ce3d030107034200");
|
||||
assert!(
|
||||
der.windows(spki_prefix.len()).any(|window| window == spki_prefix),
|
||||
"the certificate request must present an ECDSA P-256 SubjectPublicKeyInfo"
|
||||
);
|
||||
assert_eq!(der[0], 0x30, "a PKCS#10 request is a DER SEQUENCE");
|
||||
}
|
||||
|
||||
fn hex_to_bytes(hex: &str) -> Vec<u8> {
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unenrolled_deployment_holds_no_identity_and_reading_creates_none() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let store = IdentityStore::new(dir.path().join("connect"));
|
||||
|
||||
assert!(store.load().expect("load succeeds").is_none(), "an unenrolled server has no identity");
|
||||
assert!(
|
||||
!dir.path().join("connect").exists(),
|
||||
"reading must not create the store directory, let alone a key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_survives_restart_and_retry_does_not_mint_a_second() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let store = IdentityStore::new(dir.path());
|
||||
|
||||
let first = store.load_or_create().expect("first create");
|
||||
let first_key = first.public_key_der();
|
||||
|
||||
// A restart is a fresh store over the same directory.
|
||||
let reopened = IdentityStore::new(dir.path());
|
||||
let second = reopened.load_or_create().expect("second create");
|
||||
|
||||
assert_eq!(first_key, second.public_key_der(), "a retry must return the original identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_initialisation_converges_on_one_identity() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let path = dir.path().to_path_buf();
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Every thread must be spawned before any is joined: the barrier below
|
||||
// makes each one wait for all eight, so joining as we spawn would both
|
||||
// serialise the race this test exists to create and deadlock on the first
|
||||
// thread. A lazy iterator chain here is not equivalent.
|
||||
let mut handles = Vec::with_capacity(8);
|
||||
for _ in 0..8 {
|
||||
let path = path.clone();
|
||||
let started = Arc::clone(&started);
|
||||
handles.push(std::thread::spawn(move || {
|
||||
// Line the threads up so publication actually races.
|
||||
started.fetch_add(1, Ordering::SeqCst);
|
||||
while started.load(Ordering::SeqCst) < 8 {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
IdentityStore::new(&path).load_or_create().expect("create").public_key_der()
|
||||
}));
|
||||
}
|
||||
|
||||
let keys: Vec<Vec<u8>> = handles.into_iter().map(|handle| handle.join().expect("thread")).collect();
|
||||
|
||||
assert!(
|
||||
keys.windows(2).all(|pair| pair[0] == pair[1]),
|
||||
"every concurrent initialiser must observe the same device identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_key_is_refused_and_left_on_disk() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let store = IdentityStore::new(dir.path());
|
||||
store.load_or_create().expect("create");
|
||||
|
||||
let key_path = store.key_path();
|
||||
fs::write(&key_path, b"not a pkcs8 key").expect("corrupt the key");
|
||||
set_mode(&key_path, 0o600);
|
||||
|
||||
let error = store.load().expect_err("a corrupt key must fail closed");
|
||||
assert!(matches!(error, StoreError::Corrupt { .. }), "unexpected error: {error}");
|
||||
|
||||
// Regenerating would strand a certificate the control plane still trusts,
|
||||
// so the damaged file has to survive for an operator to inspect.
|
||||
assert_eq!(fs::read(&key_path).expect("key still present"), b"not a pkcs8 key");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn key_is_sealed_and_widened_permissions_are_refused() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let store = IdentityStore::new(dir.path());
|
||||
store.load_or_create().expect("create");
|
||||
|
||||
let key_path = store.key_path();
|
||||
let mode = fs::metadata(&key_path).expect("metadata").permissions().mode() & 0o7777;
|
||||
assert_eq!(mode, 0o600, "the device key must be owner-only");
|
||||
|
||||
set_mode(&key_path, 0o644);
|
||||
let error = store.load().expect_err("a world-readable key must be refused");
|
||||
assert!(matches!(error, StoreError::Permissions { mode: 0o644, .. }), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &std::path::Path, mode: u32) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set mode");
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &std::path::Path, _mode: u32) {}
|
||||
|
||||
#[test]
|
||||
fn unwritable_directory_fails_closed_without_publishing() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let store_dir = dir.path().join("sealed");
|
||||
fs::create_dir(&store_dir).expect("create store dir");
|
||||
set_mode(&store_dir, 0o500);
|
||||
|
||||
let store = IdentityStore::new(&store_dir);
|
||||
let result = store.load_or_create();
|
||||
|
||||
set_mode(&store_dir, 0o700);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(result.is_err(), "an unwritable store must not silently succeed");
|
||||
assert!(!store.key_path().exists(), "no key may be published when the write failed");
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_key_round_trips_through_pkcs8() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
let der = identity.to_pkcs8_der().expect("serialise");
|
||||
let reloaded = DeviceIdentity::from_pkcs8_der(&der).expect("deserialise");
|
||||
|
||||
assert_eq!(identity.public_key_der(), reloaded.public_key_der(), "the key must survive a round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_identity_does_not_render_key_material() {
|
||||
let identity = DeviceIdentity::generate();
|
||||
assert_eq!(format!("{identity:?}"), "DeviceIdentity(<redacted>)");
|
||||
}
|
||||
@@ -98,28 +98,12 @@ PATTERNS=(
|
||||
# key across signer, IAM, madmin, and auth tests, plus the deliberate
|
||||
# one-character variant rustfs/src/auth.rs uses to prove key comparison
|
||||
# distinguishes near-identical ids.
|
||||
# 5-7: the Connect agent protocol fixtures under protocol/agent/v1/fixtures,
|
||||
# which this repository carries as a byte-identical mirror of the Connect
|
||||
# tree (rustfs/tests/agent_protocol_fixtures.rs pins every set against its
|
||||
# MANIFEST.sha256, so the vectors cannot be reworded on this side). Their
|
||||
# subject *is* key material that the inventory schema must be unable to
|
||||
# carry and the redaction ruleset must replace, so the header has to appear
|
||||
# in the input. Entry 5 carries the closing quote, so it excuses only a
|
||||
# JSON string that ends at the header and can therefore hold no key body;
|
||||
# a header followed by one still fires. Entries 6-7 carry their bodies,
|
||||
# both unusable: 6 is a PKCS#8 wrapper whose OCTET STRING declares 32
|
||||
# bytes and holds the 7 ASCII bytes "example", and 7 spells out in the
|
||||
# body that it is not a real key.
|
||||
AWS_EXAMPLE_STEM="AKIAIOSFODNN7EXAMPL"
|
||||
AGENT_FIXTURE_RSA_BODY="MIIEowIBAAKCAQEAxEXAMPLEKEYBODYnotarealkey0000000000000000000000"
|
||||
NON_SECRET_LITERALS=(
|
||||
"-----${BEGIN_MARK} PRIVATE KEY-----\\nsecret\\n-----END PRIVATE KEY-----"
|
||||
"-----${BEGIN_MARK} RSA PRIVATE KEY-----\\nsecret\\n-----END RSA PRIVATE KEY-----"
|
||||
"${AWS_EXAMPLE_STEM}E"
|
||||
"${AWS_EXAMPLE_STEM}F"
|
||||
"\"-----${BEGIN_MARK} PRIVATE KEY-----\""
|
||||
"-----${BEGIN_MARK} PRIVATE KEY-----\\nMEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBleGFtcGxl\\n-----END PRIVATE KEY-----"
|
||||
"-----${BEGIN_MARK} RSA PRIVATE KEY-----\\n${AGENT_FIXTURE_RSA_BODY}\\nEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE=\\n-----END RSA PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
run_scan() {
|
||||
|
||||
@@ -81,6 +81,78 @@ FN_LINE = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([a-zA-Z0-9_]+)")
|
||||
|
||||
|
||||
|
||||
# A char literal is 'x' or '\n'; a lone `'` is a lifetime (`&'a str`), and
|
||||
# consuming to the next quote on one would swallow the rest of the line.
|
||||
CHAR_LITERAL = re.compile(r"'(?:[^'\\]|\\.)'")
|
||||
RAW_STRING_OPEN = re.compile(r'r(#*)"')
|
||||
|
||||
|
||||
class LiteralStripper:
|
||||
"""Blanks out literals and comments so brace matching sees only code.
|
||||
|
||||
Carries state across lines: Rust string literals — the JSON and `r#"..."#`
|
||||
fixtures these tests are full of — routinely span lines, and a per-line
|
||||
scanner falls out of phase on the first one. A `{` inside a string would
|
||||
otherwise unbalance the count and truncate a test body before its
|
||||
assertions.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.in_string = False
|
||||
self.raw_hashes = None # None when the open string is not raw
|
||||
|
||||
def feed(self, line: str) -> str:
|
||||
out = []
|
||||
i = 0
|
||||
n = len(line)
|
||||
while i < n:
|
||||
if self.in_string:
|
||||
if self.raw_hashes is not None:
|
||||
close = '"' + "#" * self.raw_hashes
|
||||
idx = line.find(close, i)
|
||||
if idx == -1:
|
||||
return "".join(out)
|
||||
i = idx + len(close)
|
||||
self.in_string = False
|
||||
self.raw_hashes = None
|
||||
continue
|
||||
if line[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if line[i] == '"':
|
||||
self.in_string = False
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
|
||||
ch = line[i]
|
||||
if ch == "/" and i + 1 < n and line[i + 1] == "/":
|
||||
break
|
||||
m = RAW_STRING_OPEN.match(line, i)
|
||||
if m:
|
||||
self.in_string = True
|
||||
self.raw_hashes = len(m.group(1))
|
||||
i = m.end()
|
||||
continue
|
||||
if ch == '"':
|
||||
self.in_string = True
|
||||
self.raw_hashes = None
|
||||
i += 1
|
||||
continue
|
||||
if ch == "'":
|
||||
cm = CHAR_LITERAL.match(line, i)
|
||||
if cm:
|
||||
i = cm.end()
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def extract_body(text: str) -> str:
|
||||
"""Return what is between the outermost braces of a scanned function."""
|
||||
start = text.find("{")
|
||||
@@ -121,8 +193,9 @@ def scan_file(path: Path):
|
||||
begun = False
|
||||
body = []
|
||||
k = j
|
||||
stripper = LiteralStripper()
|
||||
while k < len(lines):
|
||||
for ch in lines[k]:
|
||||
for ch in stripper.feed(lines[k]):
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
begun = True
|
||||
|
||||
Reference in New Issue
Block a user