From 5cb8527df8f91030aaaae5d0ac4f00b1cf143ca9 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sun, 31 May 2026 21:37:23 -0400 Subject: [PATCH] feat(#122 PR C1): signed-handoff codec + nonce table foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the tamper-evident value object the GBA Authorization Server and a Data Custodian sandbox will exchange via URL parameter during the customer-facing OAuth2 flow (replaces a shared Spring Session as the cross-app state mechanism). Mechanism only — AS and DC sides that *use* this land in subsequent PRs (C2a, C2b, C3, C4). Wire format (two dot-separated base64URL segments): {base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, payload))} Direction tag in the payload (outbound | return) prevents one direction's token from being replayed as the other. Single-use nonce table on the receiver prevents replay within a direction. Short expiry (5 min default). Codec — openespi-common.handoff - SignedHandoff sealed interface + Outbound / Return records (snake_case JSON via Jackson 3.x). - SignedHandoffCodec — HMAC-SHA256 encode + verify; rejects malformed, tampered (constant-time compare), wrong-direction, wrong-version, expired payloads. Constructor-injected signing key (≥32 chars). - InvalidHandoffException — uniform rejection signal (callers must not reveal which sub-check failed to the user-agent). Replay protection - HandoffNonceEntity implements Persistable with isNew() == true so JpaRepository.save() routes to entityManager.persist() (INSERT-only), NOT merge(); a duplicate consume surfaces as PK violation rather than silently UPDATEing the existing row. - HandoffNonceService.consume runs in Propagation.REQUIRES_NEW: a partially-completed grant must not allow the same nonce to be reused even if the surrounding business transaction rolls back. - V4__Create_Handoff_Nonces.sql — vendor-neutral DDL (H2 / MySQL / PostgreSQL). Scan-path wiring - DataCustodianApplication and TestApplication EntityScan + EnableJpaRepositories now include the handoff package. - application.yml documents espi.handoff.signing-key (default for dev; ESPI_HANDOFF_SIGNING_KEY env var in production). AS-side mirror - Deferred to PR C3 where the AS actually starts using the codec. C1 ships only what DC consumers in C2a/C2b will need. Tests - SignedHandoffCodecTest — 12 unit tests: round-trip both directions, tampered-payload / tampered-signature / wrong-key rejection (all via constant-time compare), expiry, wrong-direction, wrong-version, malformed / empty token, short signing key. - HandoffNonceServiceTest — 6 @DataJpaTest cases: first-consume success, replay rejection (PK violation), distinct-nonces independence, uniqueness over 10k generates, blank-nonce rejection, reaper sweep. Assertions are by id-lookup (not row count) because REQUIRES_NEW commits escape the @DataJpaTest rollback. Verification - openespi-common handoff tests: 18 / 18 pass. - DataCustodianApplicationH2Test (full SpringBootTest context): 3 / 3 pass — confirms the Spring auto-wiring of the dual-constructor codec (the public ctor is @Autowired; the package-private one is for tests). - openespi-datacustodian full suite: BUILD SUCCESS (97 / 97 + 1 pre- existing @Disabled skip). Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139). Co-Authored-By: Claude Opus 4.7 --- .../common/handoff/HandoffNonceEntity.java | 87 ++++++++ .../handoff/HandoffNonceRepository.java | 32 +++ .../common/handoff/HandoffNonceService.java | 85 +++++++ .../handoff/InvalidHandoffException.java | 33 +++ .../espi/common/handoff/SignedHandoff.java | 153 +++++++++++++ .../common/handoff/SignedHandoffCodec.java | 208 ++++++++++++++++++ .../migration/V4__Create_Handoff_Nonces.sql | 22 ++ .../espi/common/TestApplication.java | 8 +- .../handoff/HandoffNonceServiceTest.java | 118 ++++++++++ .../handoff/SignedHandoffCodecTest.java | 207 +++++++++++++++++ .../DataCustodianApplication.java | 6 +- .../src/main/resources/application.yml | 6 + 12 files changed, 961 insertions(+), 4 deletions(-) create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceEntity.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceRepository.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceService.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/InvalidHandoffException.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoff.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodec.java create mode 100644 openespi-common/src/main/resources/db/migration/V4__Create_Handoff_Nonces.sql create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceServiceTest.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodecTest.java diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceEntity.java new file mode 100644 index 00000000..c8dd5704 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceEntity.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.Persistable; + +import java.io.Serializable; +import java.time.Instant; + +/** + * Tracks a single-use nonce from a verified {@link SignedHandoff} on the receiver side. A row in + * this table means "this nonce has been consumed; further attempts must be rejected as replay." + * + *

{@link #nonce} is the primary key; an attempt to insert a duplicate fails the transaction and + * surfaces as a replay rejection. The receiver is expected to delete rows past {@link #expiresAt} + * via a periodic sweep (out of scope for PR C1).

+ */ +@Entity +@Table(name = "handoff_nonces") +@Getter +@Setter +@NoArgsConstructor +public class HandoffNonceEntity implements Persistable, Serializable { + + private static final long serialVersionUID = 1L; + + /** The base64URL-encoded nonce from the verified handoff payload. */ + @Id + @Column(name = "nonce", length = 64, nullable = false, updatable = false) + private String nonce; + + /** + * The {@code expiresAt} from the verified handoff payload. Used by a sweep job (not in scope + * here) to reap expired rows. NOT consulted on consume — the codec already verified + * expiry before this row was written. + */ + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + /** When this nonce was consumed; recorded for audit. */ + @Column(name = "consumed_at", nullable = false, updatable = false) + private Instant consumedAt; + + public HandoffNonceEntity(String nonce, Instant expiresAt, Instant consumedAt) { + this.nonce = nonce; + this.expiresAt = expiresAt; + this.consumedAt = consumedAt; + } + + @Override + public String getId() { + return nonce; + } + + /** + * Always {@code true} so {@code JpaRepository.save()} routes to + * {@code entityManager.persist()} (INSERT) instead of {@code merge()} (UPSERT). A duplicate + * nonce must surface as a PK violation, not silently update an existing row. + */ + @Override + @Transient + public boolean isNew() { + return true; + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceRepository.java new file mode 100644 index 00000000..ee029570 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; + +@Repository +public interface HandoffNonceRepository extends JpaRepository { + + /** + * Delete all nonces whose expiry is in the past. Returns the number of rows removed. + * Intended for a periodic sweep job (out of scope for PR C1; included as a hook). + */ + long deleteByExpiresAtBefore(Instant cutoff); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceService.java new file mode 100644 index 00000000..0c68afaa --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceService.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; + +/** + * Single-use nonce semantics for verified {@link SignedHandoff} payloads. + * + *

{@link #consume} writes a row keyed by the nonce; the PK uniqueness constraint atomically + * detects a replay and throws {@link InvalidHandoffException}. Each {@code consume} runs in its + * own transaction so the row commits even if a surrounding business transaction rolls back — + * a partially-completed grant must not allow the same nonce to be reused.

+ * + *

{@link #generate} produces a fresh 128-bit base64URL nonce for use when issuing an outbound + * handoff. Issuers do NOT pre-insert a row — the receiver's {@code consume} is what closes + * the loop.

+ */ +@Service +@RequiredArgsConstructor +public class HandoffNonceService { + + private static final int NONCE_BYTES = 16; + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private final HandoffNonceRepository repository; + private final SecureRandom random = new SecureRandom(); + private final Clock clock = Clock.systemUTC(); + + /** + * Generate a fresh 128-bit base64URL-encoded nonce. Callers attach this to a new outbound + * handoff payload; the receiver atomically detects replay via {@link #consume}. + */ + public String generate() { + byte[] bytes = new byte[NONCE_BYTES]; + random.nextBytes(bytes); + return URL_ENCODER.encodeToString(bytes); + } + + /** + * Mark a nonce as consumed. Runs in a new transaction so the row commits independently of any + * surrounding business transaction — we must never allow a nonce to be re-consumable + * because a downstream step failed. + * + * @param nonce the nonce from a verified handoff payload + * @param expiresAt the {@code expiresAt} from the same payload, recorded for the sweep job + * @throws InvalidHandoffException if this nonce has already been consumed (replay) + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void consume(String nonce, Instant expiresAt) { + if (nonce == null || nonce.isBlank()) { + throw new InvalidHandoffException("nonce is empty"); + } + try { + repository.save(new HandoffNonceEntity(nonce, expiresAt, clock.instant())); + repository.flush(); + } + catch (DataIntegrityViolationException replay) { + throw new InvalidHandoffException("nonce already consumed (replay)", replay); + } + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/InvalidHandoffException.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/InvalidHandoffException.java new file mode 100644 index 00000000..2366cb87 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/InvalidHandoffException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +/** + * Thrown when a signed handoff token is malformed, tampered, expired, replayed, or otherwise + * rejected. Callers should treat this as a 400 / "go back to start" condition — never reveal + * which sub-check failed to the user-agent. + */ +public class InvalidHandoffException extends RuntimeException { + + public InvalidHandoffException(String message) { + super(message); + } + + public InvalidHandoffException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoff.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoff.java new file mode 100644 index 00000000..dd1446fc --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoff.java @@ -0,0 +1,153 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * Tamper-evident value object carried as a URL parameter between the GBA Authorization Server and a + * Data Custodian sandbox during the customer-facing OAuth2 flow. + * + *

Why not a shared session?

+ * AS and DC are independently deployed Spring Boot applications. A shared JDBC/Redis Spring Session + * would force cookie-domain coupling, SameSite=None in dev, and an ambiguous "whose principal lives + * in the session" answer. The signed-handoff approach borrows the same idea OAuth2 itself uses for + * the {@code state} parameter: encode the cross-app state in the URL, sign it with HMAC, let each + * side keep its own session. Replay is prevented by a single-use nonce table on the receiver. + * + *

Wire format

+ * Two dot-separated base64URL segments: + *
+ *     {base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, base64URL(JSON(payload))))}
+ * 
+ * Identical wire format on both sides; the codec is duplicated by design because {@code + * openespi-authserver} is independent of {@code openespi-common}. The JSON payload structure below + * is the contract. + * + *

Directions

+ *
    + *
  • {@link Outbound} — AS to DC ("log this customer in and have them consent to this scope")
  • + *
  • {@link Return} — DC to AS ("customer logged in and made these choices")
  • + *
+ * + *

Lifetime

+ * Issued with a short expiry (default 5 minutes) and a 128-bit random nonce. The receiver verifies + * the signature, rejects expired payloads, and consumes the nonce single-use via + * {@link HandoffNonceService}. + * + * @see SignedHandoffCodec + * @see HandoffNonceService + */ +public sealed interface SignedHandoff permits SignedHandoff.Outbound, SignedHandoff.Return { + + /** Wire-format version. Increment when changing the JSON shape in a non-backwards-compatible way. */ + int CURRENT_VERSION = 1; + + /** Wire-format direction tag for {@link Outbound} payloads (AS → DC). */ + String DIRECTION_OUTBOUND = "outbound"; + + /** Wire-format direction tag for {@link Return} payloads (DC → AS). */ + String DIRECTION_RETURN = "return"; + + int version(); + String direction(); + String correlationId(); + Instant issuedAt(); + Instant expiresAt(); + String nonce(); + + /** + * AS → DC handoff. Encodes the granted scope, the client_id, and the URL the DC should + * redirect the user back to after login + Authorization Screen. + * + * @param version wire-format version, must equal {@link #CURRENT_VERSION} + * @param correlationId opaque AS-supplied trace id, echoed on the return handoff + * @param issuedAt issuance timestamp (epoch seconds) + * @param expiresAt expiry timestamp (epoch seconds); receiver rejects past this + * @param nonce base64URL-encoded 128 bits; receiver tracks single-use + * @param clientId OAuth2 {@code client_id} of the requesting third party + * @param grantedScope ESPI scope string the customer is being asked to approve + * @param returnUrl absolute URL the DC redirects the user-agent to on completion + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record Outbound( + @JsonProperty("v") int version, + @JsonProperty("dir") String direction, + @JsonProperty("cid") String correlationId, + @JsonProperty("iat") Instant issuedAt, + @JsonProperty("exp") Instant expiresAt, + @JsonProperty("nonce") String nonce, + @JsonProperty("client_id") String clientId, + @JsonProperty("scope") String grantedScope, + @JsonProperty("return_url") String returnUrl + ) implements SignedHandoff { + + /** Convenience factory that fills in {@code version} and {@code direction}. */ + public static Outbound of(String correlationId, Instant issuedAt, Instant expiresAt, String nonce, + String clientId, String grantedScope, String returnUrl) { + return new Outbound(CURRENT_VERSION, DIRECTION_OUTBOUND, correlationId, issuedAt, expiresAt, + nonce, clientId, grantedScope, returnUrl); + } + } + + /** + * DC → AS handoff. Encodes the authenticated principal and the customer's selections from + * the Authorization Screen (or a deny decision). + * + * @param version wire-format version, must equal {@link #CURRENT_VERSION} + * @param correlationId the AS-supplied trace id from the corresponding outbound + * @param issuedAt issuance timestamp + * @param expiresAt expiry timestamp + * @param nonce base64URL-encoded 128 bits; AS tracks single-use + * @param principal DC's authenticated retail-customer identifier + * @param selectedUsagePointIds usage points the customer chose to share; empty list for a + * PII-only or denied grant + * @param customerResourceUri absolute URI of the customer/PII resource the customer + * approved sharing, or {@code null} if not granted + * @param consent {@code "allow"} if the customer granted; {@code "deny"} if not + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record Return( + @JsonProperty("v") int version, + @JsonProperty("dir") String direction, + @JsonProperty("cid") String correlationId, + @JsonProperty("iat") Instant issuedAt, + @JsonProperty("exp") Instant expiresAt, + @JsonProperty("nonce") String nonce, + @JsonProperty("sub") String principal, + @JsonProperty("up") List selectedUsagePointIds, + @JsonProperty("cust_uri") String customerResourceUri, + @JsonProperty("consent") String consent + ) implements SignedHandoff { + + public static final String CONSENT_ALLOW = "allow"; + public static final String CONSENT_DENY = "deny"; + + /** Convenience factory that fills in {@code version} and {@code direction}. */ + public static Return of(String correlationId, Instant issuedAt, Instant expiresAt, String nonce, + String principal, List selectedUsagePointIds, + String customerResourceUri, String consent) { + return new Return(CURRENT_VERSION, DIRECTION_RETURN, correlationId, issuedAt, expiresAt, + nonce, principal, selectedUsagePointIds, customerResourceUri, consent); + } + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodec.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodec.java new file mode 100644 index 00000000..ac15d1c5 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodec.java @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; + +/** + * Encodes and verifies {@link SignedHandoff} tokens using HMAC-SHA256. + * + *

The token wire format is two dot-separated base64URL segments: + *

+ *     {base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, base64URL(JSON(payload))))}
+ * 
+ * + *

Verification rejects: malformed tokens, signature mismatches (constant-time comparison), + * payloads with the wrong version, expired payloads, and payloads typed as the wrong direction.

+ * + *

This codec does NOT enforce nonce single-use. Callers must additionally + * invoke {@link HandoffNonceService#consume} after a successful decode to prevent replay.

+ * + *

Identical implementation lives in {@code openespi-authserver} because that module is + * deliberately independent of {@code openespi-common}. The wire format is the contract; + * the implementations are duplicated by design.

+ */ +@Component +public class SignedHandoffCodec { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + + private final ObjectMapper objectMapper; + private final byte[] signingKey; + private final Clock clock; + + @Autowired + public SignedHandoffCodec(@Value("${espi.handoff.signing-key:dev-only-handoff-signing-key-change-me}") String signingKey) { + this(signingKey, Clock.systemUTC()); + } + + /** Test-friendly constructor. */ + SignedHandoffCodec(String signingKey, Clock clock) { + if (signingKey == null || signingKey.length() < 32) { + throw new IllegalArgumentException( + "espi.handoff.signing-key must be at least 32 characters of high-entropy material"); + } + this.signingKey = signingKey.getBytes(StandardCharsets.UTF_8); + // Jackson 3.x auto-registers java.time support via its built-in JavaTimeInitializer; + // no explicit JavaTimeModule registration needed. + this.objectMapper = JsonMapper.builder().build(); + this.clock = clock; + } + + /** + * Encode and sign an {@link SignedHandoff.Outbound}. + * + * @throws IllegalArgumentException if the payload version does not match {@link SignedHandoff#CURRENT_VERSION} + */ + public String encode(SignedHandoff.Outbound payload) { + requireCurrentVersion(payload.version()); + return encodeAndSign(payload); + } + + /** + * Encode and sign a {@link SignedHandoff.Return}. + * + * @throws IllegalArgumentException if the payload version does not match {@link SignedHandoff#CURRENT_VERSION} + */ + public String encode(SignedHandoff.Return payload) { + requireCurrentVersion(payload.version()); + return encodeAndSign(payload); + } + + /** + * Decode and verify an outbound handoff token. Verifies signature, version, direction, and expiry. + * + * @throws InvalidHandoffException if the token is malformed, tampered, expired, or the wrong direction + */ + public SignedHandoff.Outbound decodeOutbound(String token) { + SignedHandoff.Outbound payload = decodeAndVerify(token, SignedHandoff.Outbound.class); + verifyDirection(payload.direction(), SignedHandoff.DIRECTION_OUTBOUND); + verifyNotExpired(payload.expiresAt()); + return payload; + } + + /** + * Decode and verify a return handoff token. Verifies signature, version, direction, and expiry. + * + * @throws InvalidHandoffException if the token is malformed, tampered, expired, or the wrong direction + */ + public SignedHandoff.Return decodeReturn(String token) { + SignedHandoff.Return payload = decodeAndVerify(token, SignedHandoff.Return.class); + verifyDirection(payload.direction(), SignedHandoff.DIRECTION_RETURN); + verifyNotExpired(payload.expiresAt()); + return payload; + } + + private String encodeAndSign(SignedHandoff payload) { + try { + byte[] json = objectMapper.writeValueAsBytes(payload); + String encodedPayload = URL_ENCODER.encodeToString(json); + String signature = URL_ENCODER.encodeToString(hmac(encodedPayload.getBytes(StandardCharsets.US_ASCII))); + return encodedPayload + "." + signature; + } + catch (Exception e) { + throw new IllegalStateException("Failed to encode handoff payload", e); + } + } + + private T decodeAndVerify(String token, Class type) { + if (token == null || token.isBlank()) { + throw new InvalidHandoffException("token is empty"); + } + int dot = token.indexOf('.'); + if (dot < 0 || dot == token.length() - 1) { + throw new InvalidHandoffException("token is not well-formed (missing signature segment)"); + } + String encodedPayload = token.substring(0, dot); + String providedSignature = token.substring(dot + 1); + + byte[] expectedSig = hmac(encodedPayload.getBytes(StandardCharsets.US_ASCII)); + byte[] providedSig; + try { + providedSig = URL_DECODER.decode(providedSignature); + } + catch (IllegalArgumentException e) { + throw new InvalidHandoffException("signature segment is not valid base64URL", e); + } + if (!MessageDigest.isEqual(expectedSig, providedSig)) { + throw new InvalidHandoffException("signature mismatch (key drift or tampering)"); + } + + T payload; + try { + byte[] json = URL_DECODER.decode(encodedPayload); + payload = objectMapper.readValue(json, type); + } + catch (Exception e) { + throw new InvalidHandoffException("payload is not valid JSON for " + type.getSimpleName(), e); + } + + if (payload.version() != SignedHandoff.CURRENT_VERSION) { + throw new InvalidHandoffException( + "unsupported wire version " + payload.version() + " (expected " + SignedHandoff.CURRENT_VERSION + ")"); + } + return payload; + } + + private void verifyDirection(String actual, String expected) { + if (!expected.equals(actual)) { + throw new InvalidHandoffException( + "wrong direction: expected '" + expected + "' but was '" + actual + "'"); + } + } + + private void verifyNotExpired(Instant expiresAt) { + if (expiresAt == null) { + throw new InvalidHandoffException("payload is missing expiresAt"); + } + if (clock.instant().isAfter(expiresAt)) { + throw new InvalidHandoffException("payload expired at " + expiresAt); + } + } + + private byte[] hmac(byte[] data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(signingKey, HMAC_ALGORITHM)); + return mac.doFinal(data); + } + catch (Exception e) { + throw new IllegalStateException("HMAC computation failed", e); + } + } + + private static void requireCurrentVersion(int version) { + if (version != SignedHandoff.CURRENT_VERSION) { + throw new IllegalArgumentException( + "handoff payload version must be " + SignedHandoff.CURRENT_VERSION + " (was " + version + ")"); + } + } +} diff --git a/openespi-common/src/main/resources/db/migration/V4__Create_Handoff_Nonces.sql b/openespi-common/src/main/resources/db/migration/V4__Create_Handoff_Nonces.sql new file mode 100644 index 00000000..2fd6814d --- /dev/null +++ b/openespi-common/src/main/resources/db/migration/V4__Create_Handoff_Nonces.sql @@ -0,0 +1,22 @@ +/* + * OpenESPI Signed-Handoff Nonce Table (#122 PR C1) + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * Licensed under the Apache License, Version 2.0 + * + * Single-use nonce tracking for verified SignedHandoff payloads. The receiver + * inserts one row per consumed nonce; the PK uniqueness constraint atomically + * detects replay attempts. A periodic sweep (out of scope here) reaps rows + * past expires_at. + * + * Vendor-neutral DDL — H2 / MySQL / PostgreSQL all accept this verbatim. + */ + +CREATE TABLE handoff_nonces ( + nonce VARCHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP NOT NULL, + PRIMARY KEY (nonce) +); + +CREATE INDEX idx_handoff_nonces_expires_at ON handoff_nonces (expires_at); diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/TestApplication.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/TestApplication.java index a87f3e00..a8bbe942 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/TestApplication.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/TestApplication.java @@ -36,9 +36,13 @@ @EntityScan(basePackages = { "org.greenbuttonalliance.espi.common.domain.usage", "org.greenbuttonalliance.espi.common.domain.customer", - "org.greenbuttonalliance.espi.common.domain.common" + "org.greenbuttonalliance.espi.common.domain.common", + "org.greenbuttonalliance.espi.common.handoff" +}) +@EnableJpaRepositories(basePackages = { + "org.greenbuttonalliance.espi.common.repositories", + "org.greenbuttonalliance.espi.common.handoff" }) -@EnableJpaRepositories(basePackages = "org.greenbuttonalliance.espi.common.repositories") public class TestApplication { public static void main(String[] args) { diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceServiceTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceServiceTest.java new file mode 100644 index 00000000..88a6b3c7 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/HandoffNonceServiceTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import org.greenbuttonalliance.espi.common.test.BaseRepositoryTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration test for {@link HandoffNonceService} consume-once semantics. Uses {@code @DataJpaTest} + * + H2 + the Flyway migration to exercise the real PK uniqueness constraint that detects replay. + */ +@Import(HandoffNonceService.class) +class HandoffNonceServiceTest extends BaseRepositoryTest { + + @Autowired private HandoffNonceService service; + @Autowired private HandoffNonceRepository repository; + + @Test + void firstConsume_succeeds() { + String nonce = service.generate(); + service.consume(nonce, futureExpiry()); + + assertThat(repository.findById(nonce)).isPresent(); + } + + @Test + void replayedConsume_isRejected() { + String nonce = service.generate(); + Instant exp = futureExpiry(); + + service.consume(nonce, exp); + + assertThatThrownBy(() -> service.consume(nonce, exp)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("replay"); + } + + @Test + void differentNonces_canBothBeConsumed() { + String n1 = service.generate(); + String n2 = service.generate(); + Instant exp = futureExpiry(); + + service.consume(n1, exp); + service.consume(n2, exp); + + // Assert by lookup rather than count — consume() uses REQUIRES_NEW so rows committed by + // other tests in this class also exist in the DB when this test runs. + assertThat(repository.findById(n1)).isPresent(); + assertThat(repository.findById(n2)).isPresent(); + } + + @Test + void generatedNonces_areUniqueOver10kIterations() { + Set seen = new HashSet<>(); + for (int i = 0; i < 10_000; i++) { + assertThat(seen.add(service.generate())).as("collision at iteration %d", i).isTrue(); + } + } + + @Test + void blankNonce_isRejected() { + assertThatThrownBy(() -> service.consume(null, futureExpiry())) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("empty"); + assertThatThrownBy(() -> service.consume("", futureExpiry())) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("empty"); + } + + @Test + void deleteByExpiresAtBefore_reapsOnlyExpiredRows() { + Instant past = Instant.now().minus(Duration.ofHours(1)); + Instant future = Instant.now().plus(Duration.ofHours(1)); + + String pastA = service.generate(); + String pastB = service.generate(); + String futureNonce = service.generate(); + service.consume(pastA, past); + service.consume(pastB, past); + service.consume(futureNonce, future); + + repository.deleteByExpiresAtBefore(Instant.now()); + + // Assert by id-existence rather than count — REQUIRES_NEW means other tests' rows persist. + assertThat(repository.findById(pastA)).isEmpty(); + assertThat(repository.findById(pastB)).isEmpty(); + assertThat(repository.findById(futureNonce)).isPresent(); + } + + private static Instant futureExpiry() { + return Instant.now().plus(Duration.ofMinutes(5)); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodecTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodecTest.java new file mode 100644 index 00000000..8506e61b --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/handoff/SignedHandoffCodecTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * 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. + */ + +package org.greenbuttonalliance.espi.common.handoff; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Pure unit tests for {@link SignedHandoffCodec} — round-trip, tamper rejection, expiry, wrong + * direction, malformed payloads, key drift, version mismatch. + */ +class SignedHandoffCodecTest { + + private static final String KEY = "test-handoff-signing-key-must-be-at-least-32-chars"; + private static final String ALT_KEY = "different-key-but-same-length-32+aaaaaaaaaaaaaaaa"; + private static final Instant NOW = Instant.parse("2026-05-31T12:00:00Z"); + + private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + private final SignedHandoffCodec codec = new SignedHandoffCodec(KEY, clock); + + @Test + void outbound_roundTripsThroughEncodeAndDecode() { + SignedHandoff.Outbound original = SignedHandoff.Outbound.of( + "corr-1", + NOW, + NOW.plus(Duration.ofMinutes(5)), + "nonce-1", + "third_party", + "FB=4_5_15;IntervalDuration=3600", + "https://as.example.com/oauth2/authorize/continue?state=xyz"); + + String token = codec.encode(original); + SignedHandoff.Outbound decoded = codec.decodeOutbound(token); + + assertThat(decoded).isEqualTo(original); + } + + @Test + void return_roundTripsThroughEncodeAndDecode() { + SignedHandoff.Return original = SignedHandoff.Return.of( + "corr-1", + NOW, + NOW.plus(Duration.ofMinutes(5)), + "nonce-2", + "customer-42", + List.of(UUID.randomUUID(), UUID.randomUUID()), + "https://dc.example.com/.../Customer/abc", + SignedHandoff.Return.CONSENT_ALLOW); + + String token = codec.encode(original); + SignedHandoff.Return decoded = codec.decodeReturn(token); + + assertThat(decoded).isEqualTo(original); + } + + @Test + void return_withDenyAndNoCustomerUri_roundTrips() { + SignedHandoff.Return original = SignedHandoff.Return.of( + "corr-2", + NOW, + NOW.plus(Duration.ofMinutes(5)), + "nonce-3", + "customer-42", + List.of(), + null, + SignedHandoff.Return.CONSENT_DENY); + + String token = codec.encode(original); + SignedHandoff.Return decoded = codec.decodeReturn(token); + + assertThat(decoded).isEqualTo(original); + } + + @Test + void tamperedPayload_isRejected() { + String token = codec.encode(validOutbound("nonce-1")); + int dot = token.indexOf('.'); + // Flip a byte in the payload segment. + char flipped = token.charAt(0) == 'A' ? 'B' : 'A'; + String tampered = flipped + token.substring(1, dot) + token.substring(dot); + + assertThatThrownBy(() -> codec.decodeOutbound(tampered)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("signature mismatch"); + } + + @Test + void tamperedSignature_isRejected() { + String token = codec.encode(validOutbound("nonce-1")); + int dot = token.indexOf('.'); + char flipped = token.charAt(dot + 1) == 'A' ? 'B' : 'A'; + String tampered = token.substring(0, dot + 1) + flipped + token.substring(dot + 2); + + assertThatThrownBy(() -> codec.decodeOutbound(tampered)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("signature mismatch"); + } + + @Test + void keyDrift_isRejected() { + String token = codec.encode(validOutbound("nonce-1")); + SignedHandoffCodec otherSide = new SignedHandoffCodec(ALT_KEY, clock); + + assertThatThrownBy(() -> otherSide.decodeOutbound(token)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("signature mismatch"); + } + + @Test + void expiredPayload_isRejected() { + SignedHandoff.Outbound expired = SignedHandoff.Outbound.of( + "corr-1", + NOW.minus(Duration.ofMinutes(10)), + NOW.minus(Duration.ofMinutes(5)), + "nonce-1", + "tp", "FB=4_5_15", "https://x"); + + String token = codec.encode(expired); + + assertThatThrownBy(() -> codec.decodeOutbound(token)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("expired"); + } + + @Test + void outboundDecodedAsReturn_isRejectedByDirectionCheck() { + String outboundToken = codec.encode(validOutbound("nonce-1")); + + // Decoding an outbound payload via decodeReturn: signature passes (same key, same bytes), JSON + // binds (overlapping fields), but the direction tag fails the contract. + assertThatThrownBy(() -> codec.decodeReturn(outboundToken)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("wrong direction"); + } + + @Test + void wrongVersion_isRejected() { + SignedHandoff.Outbound future = new SignedHandoff.Outbound( + 99, SignedHandoff.DIRECTION_OUTBOUND, "corr-1", NOW, NOW.plus(Duration.ofMinutes(5)), + "nonce-1", "tp", "FB=4_5_15", "https://x"); + + assertThatThrownBy(() -> codec.encode(future)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("version must be " + SignedHandoff.CURRENT_VERSION); + } + + @Test + void emptyToken_isRejected() { + assertThatThrownBy(() -> codec.decodeOutbound("")) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("empty"); + assertThatThrownBy(() -> codec.decodeOutbound(null)) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("empty"); + } + + @Test + void malformedToken_isRejected() { + assertThatThrownBy(() -> codec.decodeOutbound("no-dot-here")) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("well-formed"); + assertThatThrownBy(() -> codec.decodeOutbound("trailing-dot.")) + .isInstanceOf(InvalidHandoffException.class) + .hasMessageContaining("well-formed"); + } + + @Test + void shortSigningKey_isRejectedAtConstruction() { + assertThatThrownBy(() -> new SignedHandoffCodec("too-short", clock)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 32 characters"); + } + + private static SignedHandoff.Outbound validOutbound(String nonce) { + return SignedHandoff.Outbound.of( + "corr-1", + NOW, + NOW.plus(Duration.ofMinutes(5)), + nonce, + "third_party", + "FB=4_5_15", + "https://as.example.com/return"); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/DataCustodianApplication.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/DataCustodianApplication.java index df8248cd..3e41554a 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/DataCustodianApplication.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/DataCustodianApplication.java @@ -46,10 +46,12 @@ @EntityScan(basePackages = { "org.greenbuttonalliance.espi.common.domain.usage", "org.greenbuttonalliance.espi.common.domain.customer", - "org.greenbuttonalliance.espi.common.domain.common" + "org.greenbuttonalliance.espi.common.domain.common", + "org.greenbuttonalliance.espi.common.handoff" }) @EnableJpaRepositories(basePackages = { - "org.greenbuttonalliance.espi.common.repositories" + "org.greenbuttonalliance.espi.common.repositories", + "org.greenbuttonalliance.espi.common.handoff" }) @EnableTransactionManagement public class DataCustodianApplication { diff --git a/openespi-datacustodian/src/main/resources/application.yml b/openespi-datacustodian/src/main/resources/application.yml index ab5d5995..8e6c6a81 100644 --- a/openespi-datacustodian/src/main/resources/application.yml +++ b/openespi-datacustodian/src/main/resources/application.yml @@ -141,6 +141,12 @@ espi: backchannel: client-id: ${BACKCHANNEL_CLIENT_ID:as-backchannel} client-secret: ${BACKCHANNEL_CLIENT_SECRET:change-me-in-production} + + # Shared HMAC key for the AS↔DC signed-handoff (user-flow redirect parameter). + # MUST be at least 32 chars of high-entropy material. Same value MUST be configured on the + # Authorization Server. Set via ESPI_HANDOFF_SIGNING_KEY env var in production. + handoff: + signing-key: ${ESPI_HANDOFF_SIGNING_KEY:dev-only-handoff-signing-key-change-me} # ESPI Resource Configuration resources: