From 3b5164032a76695d917e38bc9552e68a468b54c8 Mon Sep 17 00:00:00 2001 From: overtrue Date: Wed, 19 Aug 2026 12:54:50 +0800 Subject: [PATCH] feat(connect): add device identity store and registration proof A RustFS cluster device needs a durable identity before it can exchange a one-time registration token for a certificate. This adds the device-side half of that exchange, which rustfs/connect already verifies. `connect::identity` builds the canonical registration transcript frozen by protocol/agent/v1/registration-proof.md, signs it as low-S ES256, and emits the PKCS#10 certificate request Connect consumes for its SubjectPublicKeyInfo. `connect::identity_store` seals the P-256 key at mode 0600 and publishes it through a no-clobber link, so a retry or a concurrent start returns the original identity rather than minting a second one, and a corrupt or widened key is refused rather than silently replaced. The protocol fixture set is copied here byte-identically because fixture-sets.json names this repository as the consumer copy; the tests verify it against its own manifests and cross-verify Connect-produced ECDSA proofs against transcripts rebuilt locally. Nothing starts a task or touches the S3 data path: an unenrolled deployment generates no key and holds no identity. --- Cargo.lock | 1 + .../agent/v1/fixtures/auth/MANIFEST.sha256 | 5 + .../v1/fixtures/auth/accept-vectors.json | 84 ++ .../v1/fixtures/auth/certificate-profile.json | 82 ++ .../agent/v1/fixtures/auth/error-codes.json | 86 ++ .../v1/fixtures/auth/reject-vectors.json | 195 +++++ .../v1/fixtures/auth/surface-separation.json | 78 ++ .../agent/v1/fixtures/bundle/MANIFEST.sha256 | 4 + .../v1/fixtures/bundle/accept-vectors.json | 96 +++ .../agent/v1/fixtures/bundle/error-codes.json | 148 ++++ .../v1/fixtures/bundle/manifest-signing.json | 266 ++++++ .../v1/fixtures/bundle/reject-vectors.json | 331 +++++++ protocol/agent/v1/fixtures/fixture-sets.json | 52 ++ .../v1/fixtures/inventory/MANIFEST.sha256 | 7 + .../v1/fixtures/inventory/canonical-hash.json | 63 ++ .../v1/fixtures/inventory/field-registry.json | 203 +++++ .../fixtures/inventory/old-agent-vectors.json | 253 ++++++ .../v1/fixtures/inventory/reject-vectors.json | 329 +++++++ .../inventory/secret-like-vectors.json | 219 +++++ .../inventory/unknown-field-vectors.json | 230 +++++ .../v1/fixtures/inventory/valid-vectors.json | 198 +++++ .../offline-enrollment/MANIFEST.sha256 | 5 + .../offline-enrollment/accept-vectors.json | 129 +++ .../offline-enrollment/error-codes.json | 111 +++ .../offline-enrollment/reject-vectors.json | 352 ++++++++ .../offline-enrollment/trust-chain.json | 125 +++ .../offline-enrollment/trust-model.json | 299 +++++++ .../v1/fixtures/redaction/MANIFEST.sha256 | 4 + .../fixtures/redaction/allowed-vectors.json | 104 +++ .../fixtures/redaction/rejection-vectors.json | 107 +++ .../agent/v1/fixtures/redaction/ruleset.json | 116 +++ .../v1/fixtures/redaction/secret-vectors.json | 342 ++++++++ .../v1/fixtures/registration/MANIFEST.sha256 | 4 + .../fixtures/registration/accept-vectors.json | 101 +++ .../v1/fixtures/registration/error-codes.json | 80 ++ .../fixtures/registration/reject-vectors.json | 814 ++++++++++++++++++ .../v1/fixtures/registration/transcript.json | 289 +++++++ .../agent/v1/fixtures/version/MANIFEST.sha256 | 3 + .../version/additive-compatibility.json | 104 +++ .../v1/fixtures/version/field-registry.json | 34 + .../fixtures/version/negotiation-vectors.json | 132 +++ rustfs/Cargo.toml | 4 + rustfs/src/connect/identity.rs | 265 ++++++ rustfs/src/connect/identity_store.rs | 244 ++++++ rustfs/src/connect/mod.rs | 32 + rustfs/src/lib.rs | 1 + rustfs/tests/agent_protocol_fixtures.rs | 119 +++ rustfs/tests/connect_identity.rs | 506 +++++++++++ 48 files changed, 7356 insertions(+) create mode 100644 protocol/agent/v1/fixtures/auth/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/auth/accept-vectors.json create mode 100644 protocol/agent/v1/fixtures/auth/certificate-profile.json create mode 100644 protocol/agent/v1/fixtures/auth/error-codes.json create mode 100644 protocol/agent/v1/fixtures/auth/reject-vectors.json create mode 100644 protocol/agent/v1/fixtures/auth/surface-separation.json create mode 100644 protocol/agent/v1/fixtures/bundle/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/bundle/accept-vectors.json create mode 100644 protocol/agent/v1/fixtures/bundle/error-codes.json create mode 100644 protocol/agent/v1/fixtures/bundle/manifest-signing.json create mode 100644 protocol/agent/v1/fixtures/bundle/reject-vectors.json create mode 100644 protocol/agent/v1/fixtures/fixture-sets.json create mode 100644 protocol/agent/v1/fixtures/inventory/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/inventory/canonical-hash.json create mode 100644 protocol/agent/v1/fixtures/inventory/field-registry.json create mode 100644 protocol/agent/v1/fixtures/inventory/old-agent-vectors.json create mode 100644 protocol/agent/v1/fixtures/inventory/reject-vectors.json create mode 100644 protocol/agent/v1/fixtures/inventory/secret-like-vectors.json create mode 100644 protocol/agent/v1/fixtures/inventory/unknown-field-vectors.json create mode 100644 protocol/agent/v1/fixtures/inventory/valid-vectors.json create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/accept-vectors.json create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/error-codes.json create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/reject-vectors.json create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/trust-chain.json create mode 100644 protocol/agent/v1/fixtures/offline-enrollment/trust-model.json create mode 100644 protocol/agent/v1/fixtures/redaction/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/redaction/allowed-vectors.json create mode 100644 protocol/agent/v1/fixtures/redaction/rejection-vectors.json create mode 100644 protocol/agent/v1/fixtures/redaction/ruleset.json create mode 100644 protocol/agent/v1/fixtures/redaction/secret-vectors.json create mode 100644 protocol/agent/v1/fixtures/registration/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/registration/accept-vectors.json create mode 100644 protocol/agent/v1/fixtures/registration/error-codes.json create mode 100644 protocol/agent/v1/fixtures/registration/reject-vectors.json create mode 100644 protocol/agent/v1/fixtures/registration/transcript.json create mode 100644 protocol/agent/v1/fixtures/version/MANIFEST.sha256 create mode 100644 protocol/agent/v1/fixtures/version/additive-compatibility.json create mode 100644 protocol/agent/v1/fixtures/version/field-registry.json create mode 100644 protocol/agent/v1/fixtures/version/negotiation-vectors.json create mode 100644 rustfs/src/connect/identity.rs create mode 100644 rustfs/src/connect/identity_store.rs create mode 100644 rustfs/src/connect/mod.rs create mode 100644 rustfs/tests/agent_protocol_fixtures.rs create mode 100644 rustfs/tests/connect_identity.rs diff --git a/Cargo.lock b/Cargo.lock index c73ac8d64..3a6bbb935 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9142,6 +9142,7 @@ dependencies = [ "mime_guess", "opentelemetry", "opentelemetry_sdk", + "p256 0.13.2", "parking_lot", "percent-encoding", "pin-project-lite", diff --git a/protocol/agent/v1/fixtures/auth/MANIFEST.sha256 b/protocol/agent/v1/fixtures/auth/MANIFEST.sha256 new file mode 100644 index 000000000..f43d5f192 --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/MANIFEST.sha256 @@ -0,0 +1,5 @@ +3d602080f7ca4c32ba9e37ad1a32665c78560726b30aeee08fd9e95eb2f36194 accept-vectors.json +d3c19946288717088145592e0e8d6f2fa684443ba2f73d4c7bc49c415d6dd051 certificate-profile.json +060485263c51003274c056a0e04bec1b7d76157cf599ba79eebe040bc7cee71b error-codes.json +43fe297ffb512b1b9f4af62f1832f3aa3905157893bfdc3dcc6d56f5a98aaef6 reject-vectors.json +b946175b094f4a8d75091b652fbe3d4327c9c795f28e02c96e1ab90a429e418d surface-separation.json diff --git a/protocol/agent/v1/fixtures/auth/accept-vectors.json b/protocol/agent/v1/fixtures/auth/accept-vectors.json new file mode 100644 index 000000000..3181f15a0 --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/accept-vectors.json @@ -0,0 +1,84 @@ +{ + "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 + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/auth/certificate-profile.json b/protocol/agent/v1/fixtures/auth/certificate-profile.json new file mode 100644 index 000000000..55a16222c --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/certificate-profile.json @@ -0,0 +1,82 @@ +{ + "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 + } +} diff --git a/protocol/agent/v1/fixtures/auth/error-codes.json b/protocol/agent/v1/fixtures/auth/error-codes.json new file mode 100644 index 000000000..6a934b139 --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/error-codes.json @@ -0,0 +1,86 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/auth/reject-vectors.json b/protocol/agent/v1/fixtures/auth/reject-vectors.json new file mode 100644 index 000000000..b017fa9da --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/reject-vectors.json @@ -0,0 +1,195 @@ +{ + "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" + } + } +} diff --git a/protocol/agent/v1/fixtures/auth/surface-separation.json b/protocol/agent/v1/fixtures/auth/surface-separation.json new file mode 100644 index 000000000..4d3f8a895 --- /dev/null +++ b/protocol/agent/v1/fixtures/auth/surface-separation.json @@ -0,0 +1,78 @@ +{ + "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" + } + } +} diff --git a/protocol/agent/v1/fixtures/bundle/MANIFEST.sha256 b/protocol/agent/v1/fixtures/bundle/MANIFEST.sha256 new file mode 100644 index 000000000..0cbb72354 --- /dev/null +++ b/protocol/agent/v1/fixtures/bundle/MANIFEST.sha256 @@ -0,0 +1,4 @@ +4dacc8f8b7fd7f3820dbef4ea4611207dce4fb0bada287c09451499f951f64cd accept-vectors.json +afe10983a0de23cf2e1400399bb8a757de90cff2c6120d90cd83b0d28bc2cad2 error-codes.json +22133a5cbd5cd36588987d3540c9cadde063faa0ea529dc913f19ed2dc253dea manifest-signing.json +04aedbaacdac72fa2a0df22edf6a8c402645a972713778c6031d6c643976a14c reject-vectors.json diff --git a/protocol/agent/v1/fixtures/bundle/accept-vectors.json b/protocol/agent/v1/fixtures/bundle/accept-vectors.json new file mode 100644 index 000000000..e655e91eb --- /dev/null +++ b/protocol/agent/v1/fixtures/bundle/accept-vectors.json @@ -0,0 +1,96 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/bundle/error-codes.json b/protocol/agent/v1/fixtures/bundle/error-codes.json new file mode 100644 index 000000000..6a5139eb8 --- /dev/null +++ b/protocol/agent/v1/fixtures/bundle/error-codes.json @@ -0,0 +1,148 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/bundle/manifest-signing.json b/protocol/agent/v1/fixtures/bundle/manifest-signing.json new file mode 100644 index 000000000..8ba58d928 --- /dev/null +++ b/protocol/agent/v1/fixtures/bundle/manifest-signing.json @@ -0,0 +1,266 @@ +{ + "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" + } + } +} diff --git a/protocol/agent/v1/fixtures/bundle/reject-vectors.json b/protocol/agent/v1/fixtures/bundle/reject-vectors.json new file mode 100644 index 000000000..7364d6617 --- /dev/null +++ b/protocol/agent/v1/fixtures/bundle/reject-vectors.json @@ -0,0 +1,331 @@ +{ + "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" + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/fixture-sets.json b/protocol/agent/v1/fixtures/fixture-sets.json new file mode 100644 index 000000000..5ded06740 --- /dev/null +++ b/protocol/agent/v1/fixtures/fixture-sets.json @@ -0,0 +1,52 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/MANIFEST.sha256 b/protocol/agent/v1/fixtures/inventory/MANIFEST.sha256 new file mode 100644 index 000000000..cc0d06f4c --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/MANIFEST.sha256 @@ -0,0 +1,7 @@ +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 diff --git a/protocol/agent/v1/fixtures/inventory/canonical-hash.json b/protocol/agent/v1/fixtures/inventory/canonical-hash.json new file mode 100644 index 000000000..333143aa4 --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/canonical-hash.json @@ -0,0 +1,63 @@ +{ + "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" + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/field-registry.json b/protocol/agent/v1/fixtures/inventory/field-registry.json new file mode 100644 index 000000000..456674921 --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/field-registry.json @@ -0,0 +1,203 @@ +{ + "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"] } + ] + } +} diff --git a/protocol/agent/v1/fixtures/inventory/old-agent-vectors.json b/protocol/agent/v1/fixtures/inventory/old-agent-vectors.json new file mode 100644 index 000000000..8dfb3162c --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/old-agent-vectors.json @@ -0,0 +1,253 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/reject-vectors.json b/protocol/agent/v1/fixtures/inventory/reject-vectors.json new file mode 100644 index 000000000..0e9432e92 --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/reject-vectors.json @@ -0,0 +1,329 @@ +{ + "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 + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/secret-like-vectors.json b/protocol/agent/v1/fixtures/inventory/secret-like-vectors.json new file mode 100644 index 000000000..6cc1d9307 --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/secret-like-vectors.json @@ -0,0 +1,219 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/unknown-field-vectors.json b/protocol/agent/v1/fixtures/inventory/unknown-field-vectors.json new file mode 100644 index 000000000..79290b16c --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/unknown-field-vectors.json @@ -0,0 +1,230 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/inventory/valid-vectors.json b/protocol/agent/v1/fixtures/inventory/valid-vectors.json new file mode 100644 index 000000000..9d3429be6 --- /dev/null +++ b/protocol/agent/v1/fixtures/inventory/valid-vectors.json @@ -0,0 +1,198 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/offline-enrollment/MANIFEST.sha256 b/protocol/agent/v1/fixtures/offline-enrollment/MANIFEST.sha256 new file mode 100644 index 000000000..45c1b0e23 --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/MANIFEST.sha256 @@ -0,0 +1,5 @@ +5133761d19d6a64c18b6b5f871d646f6a2da4ceccc998d3cf7e22f692ca2d925 accept-vectors.json +c7da10d173e7fafa112743d9a41e2bc94df58bf88a0542d80350b74da8f382a5 error-codes.json +e98cfbedfb385defdaa9d001c85fdebcf9df2b4d054930951ff59dfa1385e52f reject-vectors.json +69d43c8266d7bb29b4df7105c49250293943583f2202b93d922d9a924fca0c09 trust-chain.json +e60cfca04bf0ce2f69495c49a95e4cc42e92e8114f6ad43449084527b06a0939 trust-model.json diff --git a/protocol/agent/v1/fixtures/offline-enrollment/accept-vectors.json b/protocol/agent/v1/fixtures/offline-enrollment/accept-vectors.json new file mode 100644 index 000000000..91b4da1eb --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/accept-vectors.json @@ -0,0 +1,129 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/offline-enrollment/error-codes.json b/protocol/agent/v1/fixtures/offline-enrollment/error-codes.json new file mode 100644 index 000000000..cb7642702 --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/error-codes.json @@ -0,0 +1,111 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/offline-enrollment/reject-vectors.json b/protocol/agent/v1/fixtures/offline-enrollment/reject-vectors.json new file mode 100644 index 000000000..8c350695b --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/reject-vectors.json @@ -0,0 +1,352 @@ +{ + "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" + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/offline-enrollment/trust-chain.json b/protocol/agent/v1/fixtures/offline-enrollment/trust-chain.json new file mode 100644 index 000000000..ccc8811bb --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/trust-chain.json @@ -0,0 +1,125 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/offline-enrollment/trust-model.json b/protocol/agent/v1/fixtures/offline-enrollment/trust-model.json new file mode 100644 index 000000000..ad674de6d --- /dev/null +++ b/protocol/agent/v1/fixtures/offline-enrollment/trust-model.json @@ -0,0 +1,299 @@ +{ + "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 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" + ] + } + } +} diff --git a/protocol/agent/v1/fixtures/redaction/MANIFEST.sha256 b/protocol/agent/v1/fixtures/redaction/MANIFEST.sha256 new file mode 100644 index 000000000..d3c9cafca --- /dev/null +++ b/protocol/agent/v1/fixtures/redaction/MANIFEST.sha256 @@ -0,0 +1,4 @@ +38e793226476bdb5f74c704c23ccc0e9ec09d51be3b51791e8b2cdfbf27a5c02 allowed-vectors.json +014b06540e664f6e38f0174746a418069d2677a2a1a4ef96c17f16dec7e47886 rejection-vectors.json +fdf1d8f4c7ed6f96026e86c7d89f56e5e08269c0b7a49a1e3c3880e5d023c600 ruleset.json +5e349d5121037a09a9b1009b08feac9f5fb4b14cd6548419b88bd9f032929b55 secret-vectors.json diff --git a/protocol/agent/v1/fixtures/redaction/allowed-vectors.json b/protocol/agent/v1/fixtures/redaction/allowed-vectors.json new file mode 100644 index 000000000..1efc3b783 --- /dev/null +++ b/protocol/agent/v1/fixtures/redaction/allowed-vectors.json @@ -0,0 +1,104 @@ +{ + "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}" + } + ] +} diff --git a/protocol/agent/v1/fixtures/redaction/rejection-vectors.json b/protocol/agent/v1/fixtures/redaction/rejection-vectors.json new file mode 100644 index 000000000..073c2cb70 --- /dev/null +++ b/protocol/agent/v1/fixtures/redaction/rejection-vectors.json @@ -0,0 +1,107 @@ +{ + "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 + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/redaction/ruleset.json b/protocol/agent/v1/fixtures/redaction/ruleset.json new file mode 100644 index 000000000..e5d75759e --- /dev/null +++ b/protocol/agent/v1/fixtures/redaction/ruleset.json @@ -0,0 +1,116 @@ +{ + "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/(?, 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= and the SAN as urn:rustfs:connect:device:, 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" + } +} diff --git a/protocol/agent/v1/fixtures/version/MANIFEST.sha256 b/protocol/agent/v1/fixtures/version/MANIFEST.sha256 new file mode 100644 index 000000000..8b41b3d4d --- /dev/null +++ b/protocol/agent/v1/fixtures/version/MANIFEST.sha256 @@ -0,0 +1,3 @@ +610eeaf44cb7a9a4ed2e4f076c2aec6050a5b81e873fa5c97845155b7ee727a2 additive-compatibility.json +3c9453cdbb34557d08ac63a1e2d7024870c8794e7d0cdc48c3a7fdfd2fa8b15f field-registry.json +3c7fe86634b7c85da87865498a6677968a78fad1be6d69ed5b3607c29d47430e negotiation-vectors.json diff --git a/protocol/agent/v1/fixtures/version/additive-compatibility.json b/protocol/agent/v1/fixtures/version/additive-compatibility.json new file mode 100644 index 000000000..9e8c8b28b --- /dev/null +++ b/protocol/agent/v1/fixtures/version/additive-compatibility.json @@ -0,0 +1,104 @@ +{ + "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": [] + } + } + ] +} diff --git a/protocol/agent/v1/fixtures/version/field-registry.json b/protocol/agent/v1/fixtures/version/field-registry.json new file mode 100644 index 000000000..dc93a901a --- /dev/null +++ b/protocol/agent/v1/fixtures/version/field-registry.json @@ -0,0 +1,34 @@ +{ + "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." + } + ] +} diff --git a/protocol/agent/v1/fixtures/version/negotiation-vectors.json b/protocol/agent/v1/fixtures/version/negotiation-vectors.json new file mode 100644 index 000000000..b357800de --- /dev/null +++ b/protocol/agent/v1/fixtures/version/negotiation-vectors.json @@ -0,0 +1,132 @@ +{ + "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 + } + } + ] +} diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 69916207e..4528e30e4 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -244,6 +244,10 @@ 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 diff --git a/rustfs/src/connect/identity.rs b/rustfs/src/connect/identity.rs new file mode 100644 index 000000000..64aa79266 --- /dev/null +++ b/rustfs/src/connect/identity.rs @@ -0,0 +1,265 @@ +// 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, +} + +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 { + 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()") + } +} + +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 { + 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>, 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, 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 { + 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 { + 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() + } +} diff --git a/rustfs/src/connect/identity_store.rs b/rustfs/src/connect/identity_store.rs new file mode 100644 index 000000000..2f4e6724e --- /dev/null +++ b/rustfs/src/connect/identity_store.rs @@ -0,0 +1,244 @@ +// 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) -> 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, 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 { + 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(()) +} diff --git a/rustfs/src/connect/mod.rs b/rustfs/src/connect/mod.rs new file mode 100644 index 000000000..3972bf21c --- /dev/null +++ b/rustfs/src/connect/mod.rs @@ -0,0 +1,32 @@ +// 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}; diff --git a/rustfs/src/lib.rs b/rustfs/src/lib.rs index b60a2bd20..a1836f4f3 100644 --- a/rustfs/src/lib.rs +++ b/rustfs/src/lib.rs @@ -80,6 +80,7 @@ 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; diff --git a/rustfs/tests/agent_protocol_fixtures.rs b/rustfs/tests/agent_protocol_fixtures.rs new file mode 100644 index 000000000..b4ce4de62 --- /dev/null +++ b/rustfs/tests/agent_protocol_fixtures.rs @@ -0,0 +1,119 @@ +// 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 = 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"); +} diff --git a/rustfs/tests/connect_identity.rs b/rustfs/tests/connect_identity.rs new file mode 100644 index 000000000..1f070a91f --- /dev/null +++ b/rustfs/tests/connect_identity.rs @@ -0,0 +1,506 @@ +// 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 { + 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(""); + 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(""); + 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 = + ::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 { + 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 { + 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(::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::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 = + ::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 = ::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 { + (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> = 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()"); +}