forked from open-telemetry/opamp-go
-
Notifications
You must be signed in to change notification settings - Fork 0
Message Attestation: Client + Server prototype #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
liustanley
wants to merge
3
commits into
main
Choose a base branch
from
message-attestation-prototype
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The answer to this differs by transport:
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.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.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
Verifyis 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.