From cc6185c9158fe98a7cd133802fd0f4eb177c0e17 Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 14:39:17 +0200 Subject: [PATCH 1/6] Use --jammy to support arm64 --- Dockerfile | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2383512..72c42f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build -FROM eclipse-temurin:17-jdk-alpine AS build +FROM eclipse-temurin:17-jdk-jammy AS build WORKDIR /app COPY .mvn/ .mvn/ COPY mvnw pom.xml ./ @@ -8,13 +8,17 @@ COPY src/ src/ RUN ./mvnw package -DskipTests -q # Stage 2: Runtime -FROM eclipse-temurin:17-jre-alpine AS runtime -RUN addgroup -S sts && adduser -S sts -G sts +FROM eclipse-temurin:17-jre-jammy AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system sts \ + && useradd --system --gid sts --no-create-home sts WORKDIR /app COPY --from=build /app/target/nxt-sts-*.jar app.jar USER sts ENV SERVER_PORT=8080 EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ - CMD wget -qO- "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 + CMD curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1 ENTRYPOINT ["java", "-jar", "app.jar"] From 741a18d87906974b5d37f919dbb75782e46c9a67 Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 15:35:21 +0200 Subject: [PATCH 2/6] Document kWh quantisation to tenths of a kWh --- README.md | 22 ++++++++++++++++++- .../java/co/nxtgrid/api/TokenRequest.java | 4 +++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 692093a..fe1ad74 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ Generates a prepayment token. | `issueDate` | `string` | Yes | ISO 8601 datetime (see note below) | | `randomNumber` | `integer` | Yes | STS 4-bit RND field — **must be 0–15** (see note below) | | `decoderKey` | `string` | Yes | Meter decoder key as a hexadecimal string (16 hex chars = 8 bytes) | -| `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`) | +| `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`; see quantization note below) | | `powerLimit` | `integer` | For `SET_POWER_LIMIT` | Maximum power limit value | > **`randomNumber` — STS protocol constraint** @@ -288,6 +288,26 @@ Generates a prepayment token. > are allowed. Any time-zone offset is **ignored**; the date and time fields are > interpreted as **UTC** for TID calculation, independent of the server's timezone. +> **`kwh` — amount quantization (0.1 kWh steps)** +> +> The STS transfer-amount field does not store an arbitrary floating-point kWh value. +> Credit is encoded in **tenths of a kWh** (0.1 kWh steps). Before packing into the +> token, this service maps the request `kwh` onto that grid as follows (inherited from +> [NectarAPI/tokens-service](https://github.com/NectarAPI/tokens-service); unchanged in NXT STS): +> +> | Requested `kwh` | Mapping | Effective credit on the token | +> |---|---|---| +> | `< 1` | ceil to the next 0.1 kWh | e.g. `0.01` → **0.1**, `0.11` → **0.2**, `0.5` → **0.5** | +> | `≥ 1` | truncate toward zero to a 0.1 kWh step | e.g. `1.19` → **1.1**, `1.99` → **1.9** | +> +> Very small top-ups therefore cannot encode as zero (`0.01` becomes `0.1`). Larger +> amounts drop any leftover fraction of a tenth rather than rounding up. +> +> **Recommendation for callers / MPM:** send `kwh` values that are already multiples of +> `0.1` so the mapping is exact, and treat billing/ledger amounts as that quantized +> value (not an unrounded intermediate float). Changing this rule would alter token +> output for the same inputs and break compatibility with existing meters and systems. + **Example — TOP_UP_KWH** ```bash diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java index ac55a9a..84897b7 100644 --- a/src/main/java/co/nxtgrid/api/TokenRequest.java +++ b/src/main/java/co/nxtgrid/api/TokenRequest.java @@ -61,7 +61,9 @@ public class TokenRequest { @Schema( description = "Amount of electricity credit in kWh. Required when type is TOP_UP_KWH " - + "(or deprecated alias TOP_UP). Must be zero or greater.", + + "(or deprecated alias TOP_UP). Must be zero or greater. Encoded in 0.1 kWh " + + "steps: values below 1 kWh are ceiled to the next tenth; values at or above " + + "1 kWh are truncated to a tenth. Prefer multiples of 0.1. See README.", example = "0.5", minimum = "0" ) From ae5a521b4666855c19300c42c9f37bf72dae7c7f Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 15:42:34 +0200 Subject: [PATCH 3/6] Harden kWh top-up against overly large amounts --- README.md | 3 + .../co/nxtgrid/api/StsExceptionHandler.java | 7 ++ .../java/co/nxtgrid/api/TokenRequest.java | 12 ++-- .../java/co/nxtgrid/token/domain/Amount.java | 28 ++++++-- .../nxtgrid/token/generators/utils/Utils.java | 10 ++- .../TokenControllerValidationTest.java | 66 +++++++++++++++++++ .../co/nxtgrid/token/domain/AmountTest.java | 27 ++++++++ 7 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/test/java/co/nxtgrid/token/domain/AmountTest.java diff --git a/README.md b/README.md index fe1ad74..33a1557 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,9 @@ Generates a prepayment token. > `0.1` so the mapping is exact, and treat billing/ledger amounts as that quantized > value (not an unrounded intermediate float). Changing this rule would alter token > output for the same inputs and break compatibility with existing meters and systems. +> +> **Maximum:** `kwh` must not exceed **1820162.4** (the STS 16-bit amount field maximum). +> Larger values are rejected with HTTP 400. **Example — TOP_UP_KWH** diff --git a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java index 855a4c8..abd764e 100644 --- a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java +++ b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import co.nxtgrid.token.exceptions.InvalidRangeException; +import co.nxtgrid.token.exceptions.InvalidUnitsPurchasedException; @RestControllerAdvice public class StsExceptionHandler { @@ -49,6 +50,12 @@ public ErrorResponse handleDomainRange(InvalidRangeException ex) { return new ErrorResponse(ex.getMessage(), null); } + @ExceptionHandler(InvalidUnitsPurchasedException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleInvalidUnitsPurchased(InvalidUnitsPurchasedException ex) { + return new ErrorResponse(ex.getMessage(), "kwh"); + } + @ExceptionHandler(UnsupportedTokenTypeException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleUnsupportedType(UnsupportedTokenTypeException ex) { diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java index 84897b7..133fdb4 100644 --- a/src/main/java/co/nxtgrid/api/TokenRequest.java +++ b/src/main/java/co/nxtgrid/api/TokenRequest.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -61,13 +62,16 @@ public class TokenRequest { @Schema( description = "Amount of electricity credit in kWh. Required when type is TOP_UP_KWH " - + "(or deprecated alias TOP_UP). Must be zero or greater. Encoded in 0.1 kWh " - + "steps: values below 1 kWh are ceiled to the next tenth; values at or above " - + "1 kWh are truncated to a tenth. Prefer multiples of 0.1. See README.", + + "(or deprecated alias TOP_UP). Must be zero or greater and at most 1820162.4 " + + "(STS 16-bit amount maximum). Encoded in 0.1 kWh steps: values below 1 kWh " + + "are ceiled to the next tenth; values at or above 1 kWh are truncated to a " + + "tenth. Prefer multiples of 0.1. See README.", example = "0.5", - minimum = "0" + minimum = "0", + maximum = "1820162.4" ) @PositiveOrZero(message = "kwh must be zero or greater") + @DecimalMax(value = "1820162.4", message = "kwh must not exceed 1820162.4") private Double kwh; @Schema( diff --git a/src/main/java/co/nxtgrid/token/domain/Amount.java b/src/main/java/co/nxtgrid/token/domain/Amount.java index f479dcb..08e1e20 100755 --- a/src/main/java/co/nxtgrid/token/domain/Amount.java +++ b/src/main/java/co/nxtgrid/token/domain/Amount.java @@ -16,14 +16,22 @@ public class Amount implements Entity { public Amount() {} + /** + * Maximum encodable STS transfer amount in kWh. The 16-bit amount field (exponent 0–3, + * mantissa 0–16383) tops out at 18_201_624 tenths of a unit, i.e. 1_820_162.4 kWh. + * Comparing against 18_201_624 here would be off by 10× (that constant is in tenths). + */ + private static final double UNITS_PURCHASED_MIN_KWH = 0; + private static final double UNITS_PURCHASED_MAX_KWH = 1_820_162.4; + /** Max value after scaling kWh → tenths; must fit STS amount encoding (exp ≤ 3). */ + private static final long MAX_AMOUNT_TENTHS = 18_201_624L; + public Amount(double unitsPurchased) throws InvalidUnitsPurchasedException, InvalidRangeException, InvalidBitStringException { - final int UNITS_PURCHASED_MIN = 0; - final int UNITS_PURCHASED_MAX = 18201624; - - if (unitsPurchased < UNITS_PURCHASED_MIN - || unitsPurchased > UNITS_PURCHASED_MAX) - throw new InvalidUnitsPurchasedException("Invalid number of units purchased!"); + if (unitsPurchased < UNITS_PURCHASED_MIN_KWH + || unitsPurchased > UNITS_PURCHASED_MAX_KWH) + throw new InvalidUnitsPurchasedException( + "kwh must be between 0 and 1820162.4 (STS maximum)"); setAmountPurchased(unitsPurchased); generateAmountBitString() ; @@ -58,7 +66,13 @@ private void setAmountPurchased(double unitsPurchased) { private void generateAmountBitString() throws InvalidUnitsPurchasedException, InvalidRangeException, InvalidBitStringException { - double refactoredAmountBits = unitsPurchased < 1 ? (int) Math.ceil(unitsPurchased * 10) : (int) (unitsPurchased * 10); + double refactoredAmountBits = unitsPurchased < 1 + ? (int) Math.ceil(unitsPurchased * 10) + : (int) (unitsPurchased * 10); + if (refactoredAmountBits > MAX_AMOUNT_TENTHS) { + throw new InvalidUnitsPurchasedException( + "kwh must be between 0 and 1820162.4 (STS maximum)"); + } BitString generatedAmountBitString = Utils.convertToBitString(refactoredAmountBits) ; generatedAmountBitString.setLength(NO_OF_BITS); setBitString(generatedAmountBitString); diff --git a/src/main/java/co/nxtgrid/token/generators/utils/Utils.java b/src/main/java/co/nxtgrid/token/generators/utils/Utils.java index 8cde94a..f7ad97f 100755 --- a/src/main/java/co/nxtgrid/token/generators/utils/Utils.java +++ b/src/main/java/co/nxtgrid/token/generators/utils/Utils.java @@ -37,7 +37,15 @@ public static BitString convertToBitString(double unitsPurchased) { mantissa /= 10; } - return new BitString((exponent << 14) + (long) Math.ceil(mantissa)); + long encoded = (exponent << 14) + (long) Math.ceil(mantissa); + // Exponent is only 2 bits in the STS amount field; values that need exp > 3 + // (or otherwise exceed 16 bits) are not representable — reject rather than + // emit bits the decoder will refuse (see convertToDouble). + if (exponent > 3 || encoded > 0xFFFFL) { + throw new IllegalArgumentException( + "amount exceeds the maximum encodable STS 16-bit value"); + } + return new BitString(encoded); } public static double convertToDouble(BitString amountBitString) diff --git a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java index da436dc..45e5b8b 100644 --- a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java +++ b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java @@ -216,6 +216,72 @@ void rejectsNegativeKwhForTopUp() throws Exception { .andExpect(jsonPath("$.field").value("kwh")); } + @Test + void acceptsMaximumEncodableKwhForTopUp() throws Exception { + mockMvc.perform( + post("/token") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "type": "TOP_UP_KWH", + "issueDate": "2024-03-15T10:30:00", + "randomNumber": 3, + "decoderKey": "0123456789ABCDEF", + "kwh": 1820162.4 + } + """ + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isString()) + .andExpect(jsonPath("$.token").isNotEmpty()); + } + + @Test + void rejectsKwhAboveStsMaximumAsBadRequest() throws Exception { + mockMvc.perform( + post("/token") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "type": "TOP_UP_KWH", + "issueDate": "2024-03-15T10:30:00", + "randomNumber": 3, + "decoderKey": "0123456789ABCDEF", + "kwh": 1820163 + } + """ + ) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("kwh must not exceed 1820162.4")) + .andExpect(jsonPath("$.field").value("kwh")); + } + + @Test + void rejectsKwhFarAboveFormerIncorrectGuardAsBadRequest() throws Exception { + mockMvc.perform( + post("/token") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "type": "TOP_UP_KWH", + "issueDate": "2024-03-15T10:30:00", + "randomNumber": 3, + "decoderKey": "0123456789ABCDEF", + "kwh": 18201625 + } + """ + ) + ) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("kwh must not exceed 1820162.4")) + .andExpect(jsonPath("$.field").value("kwh")); + } + @Test void acceptsZeroPowerLimitForSetPowerLimit() throws Exception { mockMvc.perform( diff --git a/src/test/java/co/nxtgrid/token/domain/AmountTest.java b/src/test/java/co/nxtgrid/token/domain/AmountTest.java new file mode 100644 index 0000000..3fdfc01 --- /dev/null +++ b/src/test/java/co/nxtgrid/token/domain/AmountTest.java @@ -0,0 +1,27 @@ +package co.nxtgrid.token.domain; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import co.nxtgrid.token.exceptions.InvalidUnitsPurchasedException; + +class AmountTest { + + @Test + void acceptsMaximumEncodableKwh() { + assertDoesNotThrow(() -> new Amount(1_820_162.4)); + } + + @Test + void rejectsAboveMaximumEncodableKwh() { + assertThrows(InvalidUnitsPurchasedException.class, () -> new Amount(1_820_163)); + } + + @Test + void rejectsFormerIncorrectGuardBand() { + // Previously accepted (guard was 18_201_624 kWh) but not STS-encodable. + assertThrows(InvalidUnitsPurchasedException.class, () -> new Amount(2_000_000)); + } +} From 28f55f4cab4f7a17a3f883be0f0e2cde1ef68464 Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 15:51:39 +0200 Subject: [PATCH 4/6] Update docs to warn about minute-granularity and using random number to avoid generating identical tokens in the same minute --- README.md | 20 +++++++++++++++---- .../java/co/nxtgrid/api/TokenRequest.java | 10 +++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 33a1557..2b4acd4 100644 --- a/README.md +++ b/README.md @@ -269,15 +269,24 @@ Generates a prepayment token. | `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`; see quantization note below) | | `powerLimit` | `integer` | For `SET_POWER_LIMIT` | Maximum power limit value | -> **`randomNumber` — STS protocol constraint** +> **`randomNumber` — STS protocol constraint (and same-minute uniqueness)** > > This field maps directly to the 4-bit RND field in the IEC 62055-41 token structure. > The protocol defines it as a 4-bit value, so **only 0–15 is valid** — this is not an > arbitrary API limit. Values outside this range will be rejected with HTTP 400. > -> Vary this value between consecutive token issues for the same meter. Meters reject -> tokens with the same `randomNumber` as the most recently accepted token to prevent -> replay attacks. A value of 0 is valid but should not be reused immediately. +> The STS token identifier (TID) is **minute-granular**: only the UTC date and minute of +> `issueDate` enter the TID (seconds and sub-seconds are ignored). So `10:30:00` and +> `10:30:59` produce the same TID. With identical `decoderKey`, amount, and other +> fields, two requests in the same wall-clock minute produce a **byte-identical token** +> unless `randomNumber` differs. +> +> Callers should therefore **track the last-used RND per meter** and advance it for each +> new issue (especially when vending more than once in the same minute). Meters also +> reject a token that reuses the same `randomNumber` as the most recently accepted token +> (anti-replay). A value of 0 is valid but should not be reused immediately. With only +> 16 possible RND values, high-frequency same-minute vending on one meter will exhaust +> the space unless the caller waits for the next minute or otherwise avoids collisions. > > See the full schema in the [Swagger UI](http://localhost:8080/swagger). @@ -287,6 +296,9 @@ Generates a prepayment token. > or `"2026-07-07T10:12:54.289Z"`. Optional fractional seconds and UTC/offset suffixes > are allowed. Any time-zone offset is **ignored**; the date and time fields are > interpreted as **UTC** for TID calculation, independent of the server's timezone. +> +> TID uses **minute resolution** only — changing seconds within the same minute does not +> change the token. For uniqueness of consecutive issues, vary `randomNumber` (see above). > **`kwh` — amount quantization (0.1 kWh steps)** > diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java index 133fdb4..a6bd813 100644 --- a/src/main/java/co/nxtgrid/api/TokenRequest.java +++ b/src/main/java/co/nxtgrid/api/TokenRequest.java @@ -38,7 +38,9 @@ public class TokenRequest { @Schema( description = "Token issue date/time in ISO 8601 format. Optional fractional seconds and " + "UTC/offset suffixes are accepted; any offset is ignored and the wall-clock date " - + "and time fields are interpreted as UTC for token generation.", + + "and time fields are interpreted as UTC for TID. TID is minute-granular — " + + "seconds do not differentiate tokens; vary randomNumber for same-minute issues. " + + "See README.", example = "2024-03-15T10:30:00", type = "string", format = "date-time" @@ -49,8 +51,10 @@ public class TokenRequest { @Schema( description = "STS RND field (4 bits). Must be an integer from 0 to 15. " - + "Vary between token issues to avoid duplicate-token rejection on the meter. " - + "This is not a meter serial number or other large identifier.", + + "TID is minute-granular, so identical inputs in the same UTC minute produce " + + "the same token unless this value differs — track last-used RND per meter " + + "and advance it between issues. Also avoids meter anti-replay rejection. " + + "This is not a meter serial number or other large identifier. See README.", minimum = "0", maximum = "15", example = "3" From f1044a918201061b6821dd70e80543a0df698380 Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 15:56:35 +0200 Subject: [PATCH 5/6] fix: correct DKGA04 getName() label (was DKGA02) --- .../decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java b/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java index cd80391..4f4db6f 100755 --- a/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java +++ b/src/main/java/co/nxtgrid/token/generators/decoderkeygenerator/DecoderKeyGeneratorAlgorithm04.java @@ -38,7 +38,7 @@ public DecoderKeyGeneratorAlgorithm04(BaseDate baseDate, TariffIndex tariffIndex } public String getName() { - return "DKGA02"; + return "DKGA04"; } public BaseDate getBaseDate() { From da355e13899902abd121c94f77b0f75feca2ae20 Mon Sep 17 00:00:00 2001 From: Bobby Bol Date: Thu, 6 Aug 2026 19:15:05 +0200 Subject: [PATCH 6/6] Accepted, update version to 1.0.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 23f3a1b..9fff290 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ co.nxtgrid nxt-sts - 1.0.2 + 1.0.3 org.springframework.boot