diff --git a/coordinator/internal/api/provider.go b/coordinator/internal/api/provider.go index b3b8d45c..dfa0f619 100644 --- a/coordinator/internal/api/provider.go +++ b/coordinator/internal/api/provider.go @@ -1208,6 +1208,22 @@ func (s *Server) verifyProviderAttestation(providerID string, provider *registry return } + // Reject stale attestation blobs. A replayed blob from a previously-valid + // provider could otherwise grant trust to a new connection without fresh + // hardware verification. 5 minutes matches the challenge cycle interval. + const attestationMaxAge = 5 * time.Minute + if !attestation.CheckTimestamp(result, attestationMaxAge) { + s.logger.Warn("provider attestation timestamp out of range — rejecting stale or future-dated blob", + "provider_id", providerID, + "attestation_time", result.Timestamp, + ) + result.Valid = false + result.Error = "attestation timestamp out of acceptable range" + provider.SetAttestationResult(&result) + s.registry.MarkUntrusted(providerID) + return + } + // Bind the WebSocket X25519 key used for E2E text encryption to the // attested Secure Enclave identity. If a provider wants to serve private // text, the attestation must carry the same encryption public key. diff --git a/coordinator/internal/attestation/attestation.go b/coordinator/internal/attestation/attestation.go index 9960e0f1..f7bc7b69 100644 --- a/coordinator/internal/attestation/attestation.go +++ b/coordinator/internal/attestation/attestation.go @@ -241,11 +241,19 @@ func VerifyJSON(jsonData []byte) (VerificationResult, error) { // CheckTimestamp verifies that the attestation timestamp is within the // given maximum age. This prevents replay of old attestations. +// Future-dated timestamps (clock skew beyond 30 s) are also rejected to +// prevent bypass via a far-future timestamp: time.Since is negative for +// future times, so without the upper-bound check it would always pass. func CheckTimestamp(result VerificationResult, maxAge time.Duration) bool { if result.Timestamp.IsZero() { return false } - return time.Since(result.Timestamp) <= maxAge + now := time.Now() + // Reject future-dated blobs (allow 30 s clock skew). + if result.Timestamp.After(now.Add(30 * time.Second)) { + return false + } + return now.Sub(result.Timestamp) <= maxAge } // ParseP256PublicKey parses a raw P-256 public key point.