Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e21eeda
Regenerate protobufs from message-attestation spec fork
truthbk May 19, 2026
cb7330b
Regenerate protobufs for SignedServerToAgent envelope
truthbk May 19, 2026
26d91b4
Build signing package for detached message attestation
truthbk May 19, 2026
94c621e
Address code-review fixes in signing/
truthbk May 20, 2026
37d7e2c
Polish: docstrings + test assertions
truthbk May 20, 2026
c019428
Wire client to verify SignedServerToAgent envelopes
truthbk May 20, 2026
ae83bfb
Tighten Phase 3 receive-path attestation failure handling
truthbk May 20, 2026
84298d6
Wire server to wrap outbound in SignedServerToAgent
truthbk May 20, 2026
6558494
Add end-to-end Message Attestation integration tests
truthbk May 20, 2026
81aa82b
Tighten server-side Message Attestation per code review
truthbk May 21, 2026
4a93753
Strengthen attestation tests per code review
truthbk May 21, 2026
eb88513
Fix pre-existing TestRedirectHTTP failure on master
truthbk May 21, 2026
8c3de69
Tighten signing/ package conventions and add downgrade defense-in-depth
truthbk May 25, 2026
02d83f5
Add BenchmarkSign and BenchmarkVerify for the four supported algorithms
truthbk May 25, 2026
19153f5
Add e2e test for mid-stream server key rotation
truthbk May 25, 2026
5627d93
Fix exponential backoff for message attestation retries
liustanley Jun 15, 2026
925a426
Align implementation with spec: PEM trust chain and mandatory first-m…
liustanley Jun 15, 2026
d007bbb
Add Message Attestation example with out-of-process policy server
liustanley Jun 15, 2026
6afac2a
Add opt-in TOFU flow, SAN verification, update tests
liustanley Jul 1, 2026
8f41ed1
Apply attestation-failure backoff in HTTP polling loop
liustanley Jul 2, 2026
a57a38c
Merge branch 'main' into jaime/opamp-message-attestation
liustanley Jul 2, 2026
d6feacb
Fix bugs with TOFU atomic write, leaf-key algorithm, and SAN check fo…
liustanley Jul 2, 2026
7a4d404
Fix WS client tight reconnect loop: back off on abnormal close when a…
liustanley Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[submodule "internal/opamp-spec"]
path = internal/opamp-spec
url = git@github.com:open-telemetry/opamp-spec.git
url = git@github.com:truthbk/opamp-spec.git
branch = jaime/x509-spec-full-protocol
2 changes: 2 additions & 0 deletions client/httpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ func (c *httpClient) runUntilStopped(ctx context.Context) {
c.common.PackagesStateProvider,
&c.common.PackageSyncMutex,
c.common.DownloadReporterInterval,
c.common.PayloadVerifier,
c.common.PayloadTOFUStore,
)
}

Expand Down
6 changes: 6 additions & 0 deletions client/httpclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ func TestRedirectHTTP(t *testing.T) {
MockRedirect *checkRedirectMock
}{
{
// 307 Temporary Redirect preserves both method and body, so
// the redirected POST still reaches redirectee as a POST with
// the OpAMP protobuf payload — the mock server's plain-HTTP
// dispatch path handles it. (302/303 would convert POST→GET
// in net/http, stripping Content-Type, so the mock server
// would fall through to a WS upgrade and fail with 400.)
Name: "simple redirect",
Redirector: redirectServer("http://"+redirectee.Endpoint, http.StatusTemporaryRedirect),
},
Expand Down
281 changes: 281 additions & 0 deletions client/internal/attestation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
package internal

import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"sync"
"time"

"google.golang.org/protobuf/proto"

"github.com/open-telemetry/opamp-go/protobufs"
"github.com/open-telemetry/opamp-go/signing"
)

// Sentinel errors returned by attestationState. Callers can use
// errors.Is to distinguish failure modes when terminating the
// connection.
var (
// ErrMissingTrustChain is returned when the first
// SignedServerToAgent received on a connection does not carry a
// trust_chain_response field. Per the spec this is a fatal
// handshake error.
ErrMissingTrustChain = errors.New("client: first SignedServerToAgent missing trust_chain_response")

// ErrTrustChainErrorReported is returned when the Server populates
// trust_chain_response.error_message, signalling that it cannot
// satisfy the handshake.
ErrTrustChainErrorReported = errors.New("client: server reported trust chain error")

// ErrSANMismatch is returned when the leaf certificate's Subject
// Alternative Name entries do not contain a dNSName or iPAddress
// that matches the OpAMP server the Agent is connected to. Per the
// spec this is a fatal handshake error.
ErrSANMismatch = errors.New("client: leaf certificate SAN does not match server hostname")

// ErrServerNameUnavailable is returned when payload trust
// verification is enabled but the Agent could not determine the
// server hostname to check the leaf certificate's SAN against (for
// example, the server URL was empty or unparseable). SAN
// verification is mandatory when attestation is on, so rather than
// silently skip it the handshake fails closed.
ErrServerNameUnavailable = errors.New("client: cannot verify leaf certificate SAN: server hostname unavailable")

// ErrTOFUAnchorMissing is returned during TOFU enrollment when the
// Server's TrustChainResponse does not include the expected
// tofu_trust_anchor field.
ErrTOFUAnchorMissing = errors.New("client: TOFU enrollment requested but TrustChainResponse.tofu_trust_anchor is absent")

// ErrMissingSignature is returned when a SignedServerToAgent is
// missing its signature field. Every message MUST be signed,
// including the first.
ErrMissingSignature = errors.New("client: SignedServerToAgent missing signature")

// ErrMissingPayload is returned when SignedServerToAgent.payload
// is empty. The payload carries the inner ServerToAgent; an empty
// payload would unmarshal into an empty ServerToAgent and is
// rejected eagerly.
ErrMissingPayload = errors.New("client: SignedServerToAgent missing payload")

// ErrEmptyInnerServerToAgent is returned when the inner payload
// decodes to a ServerToAgent with all default values. Defends
// against the proto3 field-1 wire-type collision: a malicious
// server that downgrades by responding with a plain ServerToAgent
// has its InstanceUid bytes misinterpreted as
// SignedServerToAgent.payload; the inner decode of those random
// UUID bytes either errors or produces a default-valued message.
// Legitimate server responses always carry at least InstanceUid
// because handleWSConnection auto-fills it (see
// server/serverimpl.go).
ErrEmptyInnerServerToAgent = errors.New("client: inner ServerToAgent decoded to all default values; likely downgrade attempt")
)

// attestationState holds per-connection state for payload trust
// verification on the Agent (client) side. Construct one per OpAMP
// connection via newAttestationState and call ProcessEnvelope on each
// inbound SignedServerToAgent.
//
// When Verifier is nil (the operator did not opt in), the OpAMP wire
// format is byte-identical to upstream and no attestationState is
// created at all; payload trust is simply not negotiated.
type attestationState struct {
verifier signing.Verifier
serverName string // hostname for SAN verification
tofuStore signing.TOFUStore // non-nil when in TOFU enrollment mode

mu sync.Mutex
firstSeen bool
leaf *x509.Certificate
}

// newAttestationState constructs a per-connection attestation state.
// verifier is nil in TOFU enrollment mode (tofuStore non-nil); in that case
// the verifier is bootstrapped from the first TrustChainResponse.
// serverName is the hostname (without port) of the OpAMP server.
func newAttestationState(verifier signing.Verifier, serverName string, tofuStore signing.TOFUStore) *attestationState {
return &attestationState{verifier: verifier, serverName: serverName, tofuStore: tofuStore}
}

// Reset clears the per-connection handshake state. After Reset, the
// next call to ProcessEnvelope is treated as if it were the first
// message on the connection — requiring trust_chain_response and
// performing a fresh chain validation.
//
// Used by transports that lack a persistent connection (the HTTP
// polling transport) to recover from server-side key rotation or
// other mid-stream handshake faults. WebSocket callers do not need
// to call Reset because a failure terminates the connection and the
// next reconnect attempt constructs a new attestationState.
func (s *attestationState) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.firstSeen = false
s.leaf = nil
}

// isAttestationFailure reports whether err originated from a payload
// trust verification problem (envelope malformed, chain validation
// failed, signature missing or invalid, etc.). Used by the WebSocket
// receive loop to distinguish attestation failures — which require
// explicit connection termination per the spec — from generic
// transport-level errors.
func isAttestationFailure(err error) bool {
if err == nil {
return false
}
return errors.Is(err, ErrMissingTrustChain) ||
errors.Is(err, ErrTrustChainErrorReported) ||
errors.Is(err, ErrSANMismatch) ||
errors.Is(err, ErrTOFUAnchorMissing) ||
errors.Is(err, ErrMissingSignature) ||
errors.Is(err, ErrMissingPayload) ||
errors.Is(err, ErrEmptyInnerServerToAgent) ||
errors.Is(err, signing.ErrChainValidation) ||
errors.Is(err, signing.ErrSignatureMismatch) ||
errors.Is(err, signing.ErrEmptyChain) ||
errors.Is(err, signing.ErrParseCertificate) ||
errors.Is(err, signing.ErrUnsupportedAlgorithm)
}

// ProcessEnvelope handles an incoming SignedServerToAgent received on
// this connection. On the first call, the envelope's certificate
// chain is validated against the verifier's pre-configured trust
// anchor pool and the resulting leaf is cached on the state. On
// subsequent calls, the envelope's signature is verified against the
// cached leaf.
//
// On success it returns the inner ServerToAgent payload bytes, which
// the caller unmarshals into a *protobufs.ServerToAgent for normal
// dispatch.
//
// On any failure — missing trust chain, chain validation failure,
// missing/invalid signature — it returns a non-nil error. Per the
// spec the caller MUST then terminate the OpAMP connection.
func (s *attestationState) ProcessEnvelope(ctx context.Context, envelope *protobufs.SignedServerToAgent) ([]byte, error) {
if envelope == nil {
return nil, errors.New("client: nil SignedServerToAgent envelope")
}
if len(envelope.Payload) == 0 {
return nil, ErrMissingPayload
}

s.mu.Lock()
defer s.mu.Unlock()

if !s.firstSeen {
chainResp := envelope.TrustChainResponse
if chainResp == nil {
return nil, ErrMissingTrustChain
}
if chainResp.ErrorMessage != "" {
return nil, fmt.Errorf("%w: %s", ErrTrustChainErrorReported, chainResp.ErrorMessage)
}
chainDER, err := parsePEMChain(chainResp.CertificateChain)
if err != nil {
return nil, fmt.Errorf("client: parse trust chain PEM: %w", err)
}

// TOFU enrollment: bootstrap the verifier from the root CA the
// Server included in tofu_trust_anchor, then persist it.
if s.tofuStore != nil {
if len(chainResp.TofuTrustAnchor) == 0 {
return nil, ErrTOFUAnchorMissing
}
v, err := signing.VerifierFromPEM(chainResp.TofuTrustAnchor)
if err != nil {
return nil, fmt.Errorf("client: TOFU: parse trust anchor: %w", err)
}
if err := s.tofuStore.Save(chainResp.TofuTrustAnchor); err != nil {
return nil, fmt.Errorf("client: TOFU: persist trust anchor: %w", err)
}
s.verifier = v
s.tofuStore = nil // enrolled; store no longer needed this session
}

leaf, err := s.verifier.ValidateChain(ctx, chainDER, time.Now())
if err != nil {
return nil, fmt.Errorf("client: validate trust chain: %w", err)
}
// SAN verification is mandatory when attestation is enabled. An
// empty serverName means the server hostname could not be
// resolved from the connection URL; fail closed rather than
// silently accept any certificate.
if s.serverName == "" {
return nil, ErrServerNameUnavailable
}
if err := leaf.VerifyHostname(s.serverName); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSANMismatch, err)
}
s.leaf = leaf
s.firstSeen = true
}

// Every message — including the first — MUST carry a signature.
if len(envelope.Signature) == 0 {
return nil, ErrMissingSignature
}
if err := s.verifier.Verify(ctx, envelope.Payload, envelope.Signature, s.leaf); err != nil {
return nil, fmt.Errorf("client: verify signature: %w", err)
}
return envelope.Payload, nil
}

// parsePEMChain decodes a concatenated PEM blob into individual DER byte
// slices ordered intermediates-first, leaf-last — the form expected by
// signing.Verifier.ValidateChain.
func parsePEMChain(pemBytes []byte) ([][]byte, error) {
var chain [][]byte
rest := pemBytes
for len(rest) > 0 {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
chain = append(chain, block.Bytes)
}
if len(chain) == 0 {
return nil, errors.New("no CERTIFICATE blocks found in PEM")
}
return chain, nil
}

// unwrapServerToAgent is a convenience that combines ProcessEnvelope
// with proto.Unmarshal of the resulting payload bytes into msg. If
// state is nil, the input bytes are unmarshalled directly as a
// ServerToAgent (the standard non-attestation path).
//
// rawProto is the protobuf bytes after any transport-level framing
// has been stripped (for WebSocket, after the wsMsgHeader varint).
func unwrapServerToAgent(ctx context.Context, state *attestationState, rawProto []byte, msg *protobufs.ServerToAgent) error {
if state == nil {
return proto.Unmarshal(rawProto, msg)
}
var envelope protobufs.SignedServerToAgent
if err := proto.Unmarshal(rawProto, &envelope); err != nil {
return fmt.Errorf("client: decode SignedServerToAgent envelope: %w", err)
}
payload, err := state.ProcessEnvelope(ctx, &envelope)
if err != nil {
return err
}
if err := proto.Unmarshal(payload, msg); err != nil {
return fmt.Errorf("client: decode inner ServerToAgent: %w", err)
}
// Defense in depth against proto3 field-1 wire-type collision.
// ProcessEnvelope's chain/signature checks already terminate the
// connection on the downgrade path that produces this state, but
// this check pins the contract: every legitimate ServerToAgent
// the agent processes has at least one non-default field
// (typically InstanceUid).
if proto.Equal(msg, &protobufs.ServerToAgent{}) {
return ErrEmptyInnerServerToAgent
}
return nil
}
Loading