Message Attestation: Client + Server prototype#2
Conversation
Implements the Message Attestation feature from opamp-spec PR open-telemetry#333. Every ServerToAgent is wrapped in a SignedServerToAgent envelope carrying a detached X.509 signature. Agents that opt in can verify that responses originate from an authorised distribution server and have not been tampered with in transit. The feature is fully opt-in and backward-compatible: agents and servers that do not configure attestation continue to use the existing wire format unchanged. New signing/ package — pluggable interfaces (Signer, Verifier, TrustAnchorProvider, TOFUStore) with in-process and remote-delegation implementations. RemoteSigner supports out-of-process policy servers as recommended in supplementary-guidelines.md. Server: Settings.PayloadSigner enables signing. The server auto-advertises OffersPayloadTrustVerification and wraps outbound messages after capability negotiation with the agent. Client: StartSettings.PayloadVerifier / PayloadTOFUStore enable verification. Chain validation (RFC 5280), SAN check, and per-message signature verification are enforced by attestationState. TOFU enrollment bootstraps the verifier from the first TrustChainResponse and persists it via TOFUStore.
…r empty string hostname
…ttestation enabled
truthbk
left a comment
There was a problem hiding this comment.
Looks consistent with the spec.
| // internal dispatch tables can refer to a specific algorithm by name. | ||
| type Algorithm uint8 | ||
|
|
||
| const ( |
There was a problem hiding this comment.
We should maybe call out in the comments what algorithms are FIPS 140-3 compatible. If they're all FIPS 140-3 valid, we should also make a mention of that.
truthbk
left a comment
There was a problem hiding this comment.
Added a couple more comments. Just dropping some thoughts that came up during the review. I think the implementation closely matches the spec with respect to attestation failures and cert expiration, but we want to be very confident about the behavior on these events we know will eventually, and regularly, occur. For instance, if mid-attestation we have a failure, I think we reset the attestation and start "fresh", I also think this is fully transparent to the user. We should also maybe make sure these events are logged.
| // for aggressive heartbeat intervals — against a potentially | ||
| // compromised server. Exponential backoff with no max elapsed time | ||
| // matches the WS client's behaviour. | ||
| attestBackoff := backoff.NewExponentialBackOff() |
There was a problem hiding this comment.
Can you please check if the backoff logic also adds jitter to the backoff period. I think it does, but please double-check 🙏
There was a problem hiding this comment.
Just confirmed that backoff adds jitter with a randomized interval: https://github.com/cenkalti/backoff/blob/v4/exponential.go#L90. NextBackOff() returns getRandomValueFromInterval(...) → a value in [interval*0.5, interval*1.5]. So each attestation retry is jittered ±50%, which avoids a fleet retrying in lockstep after a synchronized failure.
| // the caller unmarshals into a *protobufs.ServerToAgent for normal | ||
| // dispatch. | ||
| // | ||
| // On any failure — missing trust chain, chain validation failure, |
There was a problem hiding this comment.
We know the cached leaf cert will expire at some point - and we know that attestation state is kept per connection, but the OpAMP connection behavior is not totally clear to me. Are OpAMP client-server connections long-lived and reused across requests? Or is a new connection started with every OpAMP flow?
There was a problem hiding this comment.
Are OpAMP client-server connections long-lived and reused across requests? Or is a new connection started with every OpAMP flow?
The answer to this differs by transport:
- WebSocket: OpAMP client-server connection is long-lived and persistent, has one
attestationStateper connection. The leaf is validated once on the first message and pinned for the connection's lifetime, then subsequent messages are signature-only checks. On attestation failure, the connection will terminate which triggers a reconnect, leading to a fresh state and fresh handshake with the new chain. - HTTP: Relies on request/response polling so there is no persistent connection, and a new connection is started with every OpAMP flow. However, the same
attestationStateis reused across polls to avoid re-doing the full chain validation on every single request. First poll handshakes, later polls verify against the pinned leaf. On any failure,Reset()is called and the next poll re-handshakes.
We know the cached leaf cert will expire at some point
In the case that the server rotates its signing key (or the leaf expires and the server starts sending a renewed one), a signature mismatch error will occur which causes the client to reset/reconnect to trigger a fresh handshake with the new chain. But if the leaf expires with no server-side change, the expired leaf may not be re-checked since Verify is signature only. In this case, recovery would rely on the connection dropping/a failure, or a manual reset which could be problematic if this is a valid scenario.
| // can grep for one canonical phrase across both transports. | ||
| if h.attestation != nil && isAttestationFailure(err) { | ||
| h.logger.Errorf(ctx, "Payload trust verification failed; resetting attestation state: %v", err) | ||
| h.attestation.Reset() |
There was a problem hiding this comment.
I think this is how we transparently recover from an expired leaf cert - which is great.
Overview
Implements the Message Attestation feature from opamp-spec PR 333. Every
ServerToAgentis wrapped in aSignedServerToAgentenvelope carrying a detached X.509 signature. Agents that opt in can verify that responses originate from an authorised distribution server and have not been tampered with in transit — even when TLS is terminated at a load balancer.Fully opt-in and backward-compatible. Agents and servers that do not configure attestation use the existing wire format unchanged.
This PR contains only the core implementation — no tests, no examples. See message-attestation-example for a runnable demo.
API
Server —
server.SettingsClient —
client/types.StartSettingssigningpackage — interfacesProvided implementations
signing.LocalSignercrypto.Signer(file-loaded key)signing.LocalVerifier*x509.CertPoolsigning.RemoteSignerSign/ChainDER/TrustAnchorPEMto an HTTP policy serversigning.FileTOFUStoreConstructor helpers
Callbacks
No new callbacks are introduced. Existing callbacks now receive attestation-related errors through the existing surfaces:
On the client side, any attestation failure (missing trust chain, chain validation failure, SAN mismatch, bad signature) terminates the connection immediately. The WS client applies exponential backoff before reconnecting. The HTTP polling client resets attestation state and applies the same backoff before the next poll.
New error sentinel (server)
Capability bits (auto-managed)
Operators do not set these manually — they are derived from configuration:
AgentCapabilities_RequiresPayloadTrustVerificationPayloadVerifierorPayloadTOFUStoreis setAgentCapabilities_AcceptsPayloadTrustAnchorTOFUServerCapabilities_OffersPayloadTrustVerificationPayloadSigner != nilMinimal wiring example
Server:
Agent (pre-provisioned CA):
Agent (TOFU enrollment):