From ccbc8b7d596231283e15c1a360cc5a275da1cc7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 05:26:24 +0000 Subject: [PATCH 1/6] Serve signed v2 content packages (.mbu) to boards Implements the backend side of the MusallahBoard on-device architecture v2 (agent/docs/architecture.md, sections 4, 5, 9.1-9.3): - ContentSigningService loads the Ed25519 content key (musallahboard.content-signing.private-key / MUSALLAHBOARD_CONTENT_SIGNING_KEY, base64 32-byte seed) and optional previous public keys. Without a key the app still starts: signed endpoints answer 503, enrollment returns no keys, one WARN. - OfflineBundleService now builds signed content packages: mbu.json (format 2, type content, sequence = createdAt millis, files with sha256/bytes, content block) signed over "musallahboard-mbu-v2\n" + the exact stored bytes, mbu.sig, payloads and media. Media the board already holds (haveMedia) is listed but left out, and a bounded metadata cache avoids re-downloading it from R2. Media content types are restricted to the ones the agent accepts. - Admin offline-bundle download returns the signed .mbu (application/vnd.musallahboard.mbu, musallahboard-content--.mbu). - DeviceRequestAuthenticator implements device HTTP auth (X-MB-* headers, musallahboard-http-v1 message over the raw body hash, 5-minute skew). - New POST /api/agent/content-bundle (device auth) and GET /api/agent/signing-keys; enrollment returns contentSigningKeys. openapi.yaml updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- docker-compose.yml | 1 + openapi.yaml | 174 ++++++++- .../controller/AgentContentController.java | 173 +++++++++ .../controller/AgentEnrollmentController.java | 7 +- .../controller/DeviceAdminController.java | 28 +- .../board/request/ContentBundleRequest.java | 32 ++ .../board/response/AgentEnrollResponse.java | 8 + .../dto/board/response/SigningKeyView.java | 25 ++ .../board/response/SigningKeysResponse.java | 23 ++ .../security/WebSecurityConfig.java | 3 + .../http/DeviceRequestAuthenticator.java | 171 +++++++++ .../board/offline/ContentSigningService.java | 332 ++++++++++++++++++ .../service/board/offline/MbuManifest.java | 59 ++++ .../board/offline/MediaMetadataCache.java | 76 ++++ .../service/board/offline/OfflineBundle.java | 6 +- .../board/offline/OfflineBundleManifest.java | 32 -- .../board/offline/OfflineBundleService.java | 226 ++++++++---- .../resources/application.properties.example | 9 + .../AgentContentControllerTest.java | 281 +++++++++++++++ ...eviceAdminControllerOfflineBundleTest.java | 28 +- .../http/DeviceRequestAuthenticatorTest.java | 190 ++++++++++ .../offline/ContentSigningServiceTest.java | 174 +++++++++ .../board/offline/MediaMetadataCacheTest.java | 56 +++ .../offline/OfflineBundleServiceTest.java | 245 +++++++++++-- 24 files changed, 2212 insertions(+), 147 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeyView.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeysResponse.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCache.java delete mode 100644 src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleManifest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticatorTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningServiceTest.java create mode 100644 src/test/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCacheTest.java diff --git a/docker-compose.yml b/docker-compose.yml index a85e3e7..06d8996 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: SPRINGDOC_API_DOCS_ENABLED: ${SPRINGDOC_API_DOCS_ENABLED:-false} MUSALLAHBOARD_ENROLLMENT_TOKEN_PEPPER: ${MUSALLAHBOARD_ENROLLMENT_TOKEN_PEPPER} MUSALLAHBOARD_AGENT_WS_URL: ${MUSALLAHBOARD_AGENT_WS_URL} + MUSALLAHBOARD_CONTENT_SIGNING_KEY: ${MUSALLAHBOARD_CONTENT_SIGNING_KEY:-} OPENWEATHER_API_KEY: ${OPENWEATHER_API_KEY} volumes: - thumbnails:/app/thumbnails diff --git a/openapi.yaml b/openapi.yaml index 62279e5..98b87e3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -653,9 +653,10 @@ paths: - device-admin-controller /api/admin/board/devices/{deviceId}/offline-bundle: get: - description: "A zip holding one fully assembled payload per day, starting today\ - \ in the device's timezone, plus every poster image those payloads reference.\ - \ Pushed to the board by hand with mbpush. Requires board:device:read." + description: "A signed .mbu content package (format version 2) holding one fully\ + \ assembled payload per day, starting today in the device's timezone, plus\ + \ every poster image those payloads reference. Taken to the board by USB stick,\ + \ laptop or phone. Requires board:device:read." operationId: downloadOfflineBundle parameters: - in: path @@ -678,15 +679,15 @@ paths: responses: "200": content: - application/zip: + application/vnd.musallahboard.mbu: schema: type: string format: binary - description: The bundle + description: The package headers: Content-Disposition: - description: "attachment; filename=\"musallahboard--.zip\"" + description: "attachment; filename=\"musallahboard-content--.mbu\"" schema: type: string "400": @@ -713,13 +714,19 @@ paths: schema: $ref: "#/components/schemas/MessageResponse" description: A poster image could not be fetched; the message names the poster + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No content signing key is configured on the server default: content: application/json: schema: $ref: "#/components/schemas/MessageResponse" description: Request failed; body carries a human-readable message - summary: Download a content bundle for a board with no internet + summary: Download a signed content package for a board tags: - device-admin-controller /api/admin/board/devices/{deviceId}/revoke: @@ -2105,11 +2112,95 @@ paths: description: Request failed; body carries a human-readable message tags: - admin-controller + /api/agent/content-bundle: + post: + description: "Device-authenticated with the X-MB-* headers (Ed25519 signature\ + \ by the enrolled device key over musallahboard-http-v1, method, path, device\ + \ id, timestamp and the body's SHA-256). Returns a signed .mbu content package\ + \ starting today in the device's timezone. Media whose SHA-256 is in haveMedia\ + \ is listed and signed in mbu.json but left out of the zip." + operationId: downloadContentBundle + parameters: + - description: Enrolled device id + in: header + name: X-MB-Device-Id + required: true + schema: + type: string + format: uuid + - description: Unix milliseconds; must be within 5 minutes of the server clock + in: header + name: X-MB-Timestamp + required: true + schema: + type: integer + format: int64 + - description: Base64 Ed25519 signature by the device key + in: header + name: X-MB-Signature + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ContentBundleRequest" + responses: + "200": + content: + application/vnd.musallahboard.mbu: + schema: + type: string + format: binary + description: The signed content package + headers: + Content-Disposition: + description: "attachment; filename=\"musallahboard-content--.mbu\"" + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Malformed body, days outside 1-31, or a bad haveMedia entry" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Missing or bad device signature, clock skew over 5 minutes,\ + \ or an unknown or revoked device" + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: A poster image could not be fetched; the message names the poster + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: No content signing key is configured on the server + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + security: [] + summary: Fetch this board's signed content package (online sync) + tags: + - agent-content-controller /api/agent/enroll: post: description: Called once per device by the MusallahBoard agent. The returned websocketUrl is persisted verbatim into the agent's config and never requested - again. + again. contentSigningKeys are the public content keys the agent pins in its + trust store. operationId: enrollAgent requestBody: content: @@ -2145,6 +2236,29 @@ paths: summary: Exchange a one-time enrollment token for a device identity tags: - agent-enrollment-controller + /api/agent/signing-keys: + get: + description: "Current key first, then previous keys still valid during a rotation.\ + \ Empty when the server has no content signing key. Used by `musallahboard-agent\ + \ trust fetch` on boards enrolled before signed content existed." + operationId: getSigningKeys + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/SigningKeysResponse" + description: OK + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + security: [] + summary: Public content signing keys boards should trust + tags: + - agent-content-controller /api/auth/change-password: post: description: "Revokes every refresh token for the user, so all sessions must\ @@ -3230,6 +3344,13 @@ components: AgentEnrollResponse: type: object properties: + contentSigningKeys: + type: array + description: "Public content signing keys the agent pins at enrollment. Empty\ + \ when the server has no content key configured. Optional so agents built\ + \ before it existed still decode the response." + items: + $ref: "#/components/schemas/SigningKeyView" deviceId: type: string format: uuid @@ -3435,6 +3556,21 @@ components: - TIMEOUT - REJECTED - EXPIRED + ContentBundleRequest: + type: object + properties: + days: + type: integer + format: int32 + default: 7 + maximum: 31 + minimum: 1 + haveMedia: + type: array + items: + type: string + pattern: "^[0-9a-f]{64}$" + maxItems: 2000 CreateCalendarEventRequest: type: object properties: @@ -4294,6 +4430,26 @@ components: type: array items: type: string + SigningKeyView: + type: object + properties: + keyId: + type: string + example: 0123456789abcdef + publicKey: + type: string + required: + - keyId + - publicKey + SigningKeysResponse: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/SigningKeyView" + required: + - content SignupRequest: type: object properties: diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java new file mode 100644 index 0000000..7a127cb --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java @@ -0,0 +1,173 @@ +package com.ibrasoft.lensbridge.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.board.request.ContentBundleRequest; +import com.ibrasoft.lensbridge.dto.board.response.SigningKeysResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator; +import com.ibrasoft.lensbridge.service.board.offline.ContentSigningService; +import com.ibrasoft.lensbridge.service.board.offline.OfflineBundle; +import com.ibrasoft.lensbridge.service.board.offline.OfflineBundleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Endpoints a board's agent calls on its own behalf once enrolled: fetching signed content + * for online sync, and fetching the content keys to pin. See MusallahBoard + * {@code agent/docs/architecture.md}, sections 9.1 to 9.3. + *

+ * Both paths are {@code permitAll} in {@code WebSecurityConfig}. The signing keys are public + * by nature; the content bundle authenticates the device itself with + * {@link DeviceRequestAuthenticator}, which needs the raw body and so cannot sit behind the + * JWT filter chain. + */ +@RestController +@RequestMapping("/api/agent") +@RequiredArgsConstructor +@Slf4j +public class AgentContentController { + + private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$"); + + private final DeviceRequestAuthenticator deviceAuthenticator; + private final OfflineBundleService offlineBundleService; + private final ContentSigningService signingService; + private final ObjectMapper objectMapper; + + /** + * The body is taken as raw bytes and parsed here, after authentication, so the signature + * check hashes exactly what the device sent. Order of checks: device auth (401), then the + * body (400), then building (404/409 in theory, 503 without a signing key, 502 for an + * unfetchable poster). + *

+ * Like the admin download, the package is built completely and then written straight to + * the servlet response, so every failure is still a JSON error rather than a truncated zip. + * No {@code produces} on the mapping, for the same reason: it would stop Spring rendering + * the JSON error bodies. + *

+ * Device state (last seen, heartbeat) is left alone: that belongs to the WebSocket session. + */ + @Operation(operationId = "downloadContentBundle", + summary = "Fetch this board's signed content package (online sync)", + description = "Device-authenticated with the X-MB-* headers (Ed25519 signature by the enrolled " + + "device key over musallahboard-http-v1, method, path, device id, timestamp and the " + + "body's SHA-256). Returns a signed .mbu content package starting today in the device's " + + "timezone. Media whose SHA-256 is in haveMedia is listed and signed in mbu.json but " + + "left out of the zip.") + @SecurityRequirements + @Parameters({ + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.DEVICE_ID_HEADER, required = true, + description = "Enrolled device id", schema = @Schema(type = "string", format = "uuid")), + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.TIMESTAMP_HEADER, required = true, + description = "Unix milliseconds; must be within 5 minutes of the server clock", + schema = @Schema(type = "integer", format = "int64")), + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.SIGNATURE_HEADER, required = true, + description = "Base64 Ed25519 signature by the device key", schema = @Schema(type = "string")) + }) + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = false, content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ContentBundleRequest.class))) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "The signed content package", + content = @Content(mediaType = OfflineBundleService.MBU_CONTENT_TYPE, + schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "400", description = "Malformed body, days outside 1-31, or a bad haveMedia entry", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "Missing or bad device signature, clock skew over 5 minutes, " + + "or an unknown or revoked device", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "502", description = "A poster image could not be fetched; the message names the poster", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "503", description = "No content signing key is configured on the server", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @PostMapping("/content-bundle") + public void contentBundle(@RequestBody(required = false) byte[] body, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + Device device = deviceAuthenticator.authenticate(request, body); + ContentBundleRequest parsed = parse(body); + + int days = parsed.getDays() == null ? ContentBundleRequest.DEFAULT_DAYS : parsed.getDays(); + Set haveMedia = haveMedia(parsed.getHaveMedia()); + + OfflineBundle bundle = offlineBundleService.build(device.getId(), days, haveMedia); + response.setStatus(HttpStatus.OK.value()); + response.setContentType(OfflineBundleService.MBU_CONTENT_TYPE); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(bundle.filename()).build().toString()); + bundle.writeTo(response.getOutputStream()); + response.flushBuffer(); + } + + @Operation(operationId = "getSigningKeys", + summary = "Public content signing keys boards should trust", + description = "Current key first, then previous keys still valid during a rotation. Empty when " + + "the server has no content signing key. Used by `musallahboard-agent trust fetch` " + + "on boards enrolled before signed content existed.") + @SecurityRequirements + @GetMapping("/signing-keys") + public ResponseEntity signingKeys() { + return ResponseEntity.ok(new SigningKeysResponse(signingService.publicKeys())); + } + + /** An absent or empty body means all defaults. Unknown fields are ignored (forward compatible). */ + private ContentBundleRequest parse(byte[] body) { + if (body == null || body.length == 0) { + return new ContentBundleRequest(); + } + try { + ContentBundleRequest parsed = objectMapper.readValue(body, ContentBundleRequest.class); + return parsed == null ? new ContentBundleRequest() : parsed; + } catch (IOException e) { + throw badRequest("Request body must be a JSON object like {\"days\": 7, \"haveMedia\": []}"); + } + } + + private static Set haveMedia(List hashes) { + if (hashes == null) return Set.of(); + if (hashes.size() > ContentBundleRequest.MAX_HAVE_MEDIA) { + throw badRequest("haveMedia may list at most " + ContentBundleRequest.MAX_HAVE_MEDIA + " hashes"); + } + Set set = new LinkedHashSet<>(); + for (String hash : hashes) { + if (hash == null || !SHA256_HEX.matcher(hash).matches()) { + throw badRequest("haveMedia entries must be 64 lowercase hex characters (SHA-256)"); + } + set.add(hash); + } + return set; + } + + private static ApiResponseException badRequest(String message) { + return new ApiResponseException(HttpStatus.BAD_REQUEST, new MessageResponse(message)); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java index f51708f..64ba6b2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java @@ -6,6 +6,7 @@ import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome; +import com.ibrasoft.lensbridge.service.board.offline.ContentSigningService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -36,6 +37,7 @@ public class AgentEnrollmentController { private final DeviceEnrollmentService enrollmentService; + private final ContentSigningService signingService; @Value("${musallahboard.agent.websocketUrl}") private String websocketUrl; @@ -43,7 +45,8 @@ public class AgentEnrollmentController { @Operation(operationId = "enrollAgent", summary = "Exchange a one-time enrollment token for a device identity", description = "Called once per device by the MusallahBoard agent. The returned websocketUrl is " - + "persisted verbatim into the agent's config and never requested again.") + + "persisted verbatim into the agent's config and never requested again. " + + "contentSigningKeys are the public content keys the agent pins in its trust store.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "Device enrolled; identity and websocket URL returned"), @ApiResponse(responseCode = "400", description = "Public key malformed or unsupported", @@ -69,6 +72,8 @@ public ResponseEntity enroll(@Valid @RequestBody AgentEnrol case Outcome.Ok ok -> ResponseEntity.ok(AgentEnrollResponse.builder() .deviceId(ok.device().getId()) .websocketUrl(websocketUrl) + // Pinned by the agent over this same TLS connection; empty without a key. + .contentSigningKeys(signingService.publicKeys()) .build()); case Outcome.InvalidPublicKey bad -> throw new ApiResponseException(HttpStatus.BAD_REQUEST, new MessageResponse("Invalid public key: " + bad.reason())); diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java index c121cd4..9fae26c 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/DeviceAdminController.java @@ -195,25 +195,29 @@ public ResponseEntity> recentCommands(@PathVariable UUID devic } /** - * The bundle is built completely before the first byte goes out, so every failure (bad - * {@code days}, unknown or revoked device, an unfetchable poster image) is still a normal - * JSON error response rather than a truncated zip. + * The signed content package ({@code .mbu}) an admin carries to a board, with every media + * file included. It is the same package an online board syncs for itself, just with no + * {@code haveMedia} to leave anything out. + *

+ * The package is built completely before the first byte goes out, so every failure (bad + * {@code days}, unknown or revoked device, no signing key, an unfetchable poster image) is + * still a normal JSON error response rather than a truncated zip. *

* Written straight to the servlet response on the request thread. A * {@code StreamingResponseBody} would finish on an ASYNC re-dispatch, where * {@code AuthTokenFilter} (a {@code OncePerRequestFilter}) does not run, so the * authorization filter would reject the dispatch and append a 401 body to the zip. * No {@code produces} on the mapping either: it would stop Spring rendering the JSON - * error bodies, since the only producible type would be the zip. + * error bodies, since the only producible type would be the package. */ @Operation(operationId = "downloadOfflineBundle", - summary = "Download a content bundle for a board with no internet", - description = "A zip holding one fully assembled payload per day, starting today in the " - + "device's timezone, plus every poster image those payloads reference. Pushed to " - + "the board by hand with mbpush.") + summary = "Download a signed content package for a board", + description = "A signed .mbu content package (format version 2) holding one fully assembled " + + "payload per day, starting today in the device's timezone, plus every poster image those " + + "payloads reference. Taken to the board by USB stick, laptop or phone.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "The bundle", - content = @Content(mediaType = "application/zip", + @ApiResponse(responseCode = "200", description = "The package", + content = @Content(mediaType = OfflineBundleService.MBU_CONTENT_TYPE, schema = @Schema(type = "string", format = "binary"))), @ApiResponse(responseCode = "400", description = "days outside 1-31", content = @Content(schema = @Schema(implementation = MessageResponse.class))), @@ -222,6 +226,8 @@ public ResponseEntity> recentCommands(@PathVariable UUID devic @ApiResponse(responseCode = "409", description = "Device is revoked", content = @Content(schema = @Schema(implementation = MessageResponse.class))), @ApiResponse(responseCode = "502", description = "A poster image could not be fetched; the message names the poster", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "503", description = "No content signing key is configured on the server", content = @Content(schema = @Schema(implementation = MessageResponse.class))) }) @GetMapping("/{deviceId}/offline-bundle") @@ -232,7 +238,7 @@ public void offlineBundle( HttpServletResponse response) throws IOException { OfflineBundle bundle = offlineBundleService.build(deviceId, days); response.setStatus(HttpStatus.OK.value()); - response.setContentType("application/zip"); + response.setContentType(OfflineBundleService.MBU_CONTENT_TYPE); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(bundle.filename()).build().toString()); bundle.writeTo(response.getOutputStream()); diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java new file mode 100644 index 0000000..0192db3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java @@ -0,0 +1,32 @@ +package com.ibrasoft.lensbridge.dto.board.request; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * Body of {@code POST /api/agent/content-bundle}. Both fields are optional. + *

+ * Parsed by hand from the raw request bytes rather than bound by Spring, because the device + * signature covers the exact bytes that were sent; see + * {@link com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator}. + */ +@Data +public class ContentBundleRequest { + + public static final int DEFAULT_DAYS = 7; + public static final int MAX_HAVE_MEDIA = 2000; + + /** Days in the package, starting today in the device's timezone. */ + @Schema(minimum = "1", maximum = "31", defaultValue = "" + DEFAULT_DAYS) + private Integer days; + + /** + * SHA-256 (64 lowercase hex) of media the board's store already holds. Those files are left + * out of the zip but stay listed, and signed, in {@code mbu.json}. + */ + @ArraySchema(maxItems = MAX_HAVE_MEDIA, schema = @Schema(pattern = "^[0-9a-f]{64}$")) + private List haveMedia; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java index 9e94285..86fb878 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java @@ -3,6 +3,7 @@ import lombok.Builder; import lombok.Data; +import java.util.List; import java.util.UUID; @Data @@ -13,4 +14,11 @@ public class AgentEnrollResponse { /** Absolute URL the agent should connect to for the persistent command channel. */ private String websocketUrl; + + /** + * Public content signing keys the agent pins at enrollment, over the same TLS connection + * that establishes its identity. Empty when the server has no content key configured. + * Optional in the spec so agents built before it existed still decode the response. + */ + private List contentSigningKeys; } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeyView.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeyView.java new file mode 100644 index 0000000..6c998ae --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeyView.java @@ -0,0 +1,25 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One public signing key as boards pin it in {@code /etc/musallahboard/trust.json}. Same + * shape wherever a key is handed out: enrollment's {@code contentSigningKeys} and + * {@code GET /api/agent/signing-keys}. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SigningKeyView { + + /** Lowercase hex of the first 8 bytes of SHA-256 over the raw public key. */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "0123456789abcdef") + private String keyId; + + /** Base64 of the raw 32-byte Ed25519 public key. */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String publicKey; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeysResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeysResponse.java new file mode 100644 index 0000000..77a446b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/SigningKeysResponse.java @@ -0,0 +1,23 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * {@code GET /api/agent/signing-keys}. Only the {@code content} role exists here: release keys + * are compiled into the agent and never come from the backend, which is the point of having + * two trust roots. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SigningKeysResponse { + + /** Current content key first, then any previous keys still valid during a rotation. Empty when signing is not configured. */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List content; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index ad46db5..c36f9fb 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -92,6 +92,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/refresh-musallahboard").permitAll() .requestMatchers("/api/agent/enroll").permitAll() .requestMatchers("/api/agent/ws").permitAll() + // Public keys, and device-signed requests authenticated in the controller. + .requestMatchers("/api/agent/signing-keys").permitAll() + .requestMatchers("/api/agent/content-bundle").permitAll() .requestMatchers("/api/dashboard/ws/**").permitAll() // Disable these endpoints in production; they are only for local dev and CI. .requestMatchers("/swagger-ui/**", "/swagger-ui.html", diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java new file mode 100644 index 0000000..0f58c10 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java @@ -0,0 +1,171 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Authenticates a plain HTTP request made by a board's agent with its enrolled Ed25519 key. + * See MusallahBoard {@code agent/docs/architecture.md}, section 9.2. + *

+ * The agent sends three headers: {@value #DEVICE_ID_HEADER}, {@value #TIMESTAMP_HEADER} + * (Unix milliseconds) and {@value #SIGNATURE_HEADER} (base64 Ed25519 signature). The signed + * message is UTF-8, newline separated, with no trailing newline: + * + *

+ * musallahboard-http-v1
+ * <METHOD>
+ * <request path, no query string>
+ * <deviceId>
+ * <timestamp>
+ * <lowercase hex SHA-256 of the request body (of the empty string if none)>
+ * 
+ * + * The prefix separates these signatures from the WebSocket handshake's + * ({@code musallahboard-auth-v1}) and from package signatures ({@code musallahboard-mbu-v2}), + * so a signature made for one can never be replayed as another. + *

+ * Deliberately a component the endpoint calls, not a servlet filter: only a couple of agent + * endpoints use it, and the caller hands over the raw body bytes it already read, so the + * hash covers exactly what was sent rather than a re-serialization of a bound object. The + * paths are {@code permitAll} in {@code WebSecurityConfig}; this is their authentication. + *

+ * Replays inside the five-minute window are accepted. That is by design: the endpoints this + * guards are idempotent reads, so a replay only fetches the same content again. + */ +@Component +@Slf4j +public class DeviceRequestAuthenticator { + + public static final String DEVICE_ID_HEADER = "X-MB-Device-Id"; + public static final String TIMESTAMP_HEADER = "X-MB-Timestamp"; + public static final String SIGNATURE_HEADER = "X-MB-Signature"; + + public static final String VERSION_PREFIX = "musallahboard-http-v1"; + + /** How far the device's clock may be from ours, either way. */ + public static final Duration MAX_SKEW = Duration.ofMinutes(5); + + private final DeviceRepository deviceRepository; + private final Ed25519Verifier verifier; + private final Clock clock; + + @Autowired + public DeviceRequestAuthenticator(DeviceRepository deviceRepository, Ed25519Verifier verifier) { + this(deviceRepository, verifier, Clock.systemUTC()); + } + + DeviceRequestAuthenticator(DeviceRepository deviceRepository, Ed25519Verifier verifier, Clock clock) { + this.deviceRepository = deviceRepository; + this.verifier = verifier; + this.clock = clock; + } + + /** + * @param request the incoming request; its method, path ({@link HttpServletRequest#getRequestURI()}, + * which never includes the query string) and headers are read + * @param body the raw body bytes exactly as received; null or empty for no body + * @return the authenticated, non-revoked device + * @throws ApiResponseException 401 with a JSON message when any check fails + */ + public Device authenticate(HttpServletRequest request, byte[] body) { + String deviceIdHeader = request.getHeader(DEVICE_ID_HEADER); + String timestampHeader = request.getHeader(TIMESTAMP_HEADER); + String signatureHeader = request.getHeader(SIGNATURE_HEADER); + if (isBlank(deviceIdHeader) || isBlank(timestampHeader) || isBlank(signatureHeader)) { + throw unauthorized("missing " + DEVICE_ID_HEADER + ", " + TIMESTAMP_HEADER + + " or " + SIGNATURE_HEADER + " header"); + } + + UUID deviceId; + try { + deviceId = UUID.fromString(deviceIdHeader.trim()); + } catch (IllegalArgumentException e) { + throw unauthorized(DEVICE_ID_HEADER + " is not a UUID"); + } + + long timestamp; + try { + timestamp = Long.parseLong(timestampHeader.trim()); + } catch (NumberFormatException e) { + throw unauthorized(TIMESTAMP_HEADER + " is not Unix milliseconds"); + } + // Checked before the database is touched, and worded so a board with a wrong clock + // can tell what is wrong from its sync error. + long skew = Math.abs(clock.millis() - timestamp); + if (skew > MAX_SKEW.toMillis()) { + throw unauthorized("timestamp is more than " + MAX_SKEW.toMinutes() + + " minutes from the server clock (check the board's time)"); + } + + byte[] signature; + try { + signature = Base64.getDecoder().decode(signatureHeader.trim()); + } catch (IllegalArgumentException e) { + throw unauthorized(SIGNATURE_HEADER + " is not base64"); + } + + // Unknown and revoked answer alike: the difference is none of the caller's business. + Device device = deviceRepository.findById(deviceId).orElse(null); + if (device == null || device.getRevokedAt() != null || device.getPublicKey() == null) { + log.warn("Device HTTP auth rejected for {} on {} {}: unknown or revoked device", + deviceId, request.getMethod(), request.getRequestURI()); + throw unauthorized("unknown or revoked device"); + } + + byte[] message = message(request.getMethod(), request.getRequestURI(), deviceId, timestamp, body); + if (!verifier.verify(device.getPublicKey(), message, signature)) { + log.warn("Device HTTP auth rejected for {} on {} {}: bad signature", + deviceId, request.getMethod(), request.getRequestURI()); + throw unauthorized("bad signature"); + } + return device; + } + + /** + * The exact bytes a device signs. Public so tests (and anything else in this codebase that + * ever needs to act as a device) build it the same way. + */ + public static byte[] message(String method, String path, UUID deviceId, long timestamp, byte[] body) { + String s = VERSION_PREFIX + "\n" + + method + "\n" + + path + "\n" + + deviceId + "\n" + + timestamp + "\n" + + sha256Hex(body == null ? new byte[0] : body); + return s.getBytes(StandardCharsets.UTF_8); + } + + private static ApiResponseException unauthorized(String reason) { + return new ApiResponseException(HttpStatus.UNAUTHORIZED, + new MessageResponse("Device authentication failed: " + reason)); + } + + private static String sha256Hex(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is mandatory in every JRE", e); + } + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java new file mode 100644 index 0000000..b72c90b --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java @@ -0,0 +1,332 @@ +package com.ibrasoft.lensbridge.service.board.offline; + +import com.ibrasoft.lensbridge.dto.board.response.SigningKeyView; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.spec.NamedParameterSpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Holds the backend's content signing key and signs {@code .mbu} manifests with it. + * See MusallahBoard {@code agent/docs/architecture.md}, sections 4.2, 5 and 9.3. + *

+ * The content key is one of two trust roots on a board. It can only vouch for content + * (payloads and posters for one device); software is signed by a separate release key this + * backend never sees. So a leak of this key puts the wrong posters on screens, nothing worse, + * and it is rotated by moving the old public key into + * {@code musallahboard.content-signing.previous-public-keys} for as long as boards may still + * hold packages signed with it. + *

+ * Key formats are the ones every tool in the system shares ({@code mbpack keygen}, the Node + * packer, the Go agent): the private key is configured as the base64 of the 32-byte Ed25519 + * seed, and public keys travel as the base64 of the raw 32-byte point. The key id is the + * lowercase hex of the first 8 bytes of SHA-256 over that raw public key. + *

+ * The key is optional. A deployment without one still starts and serves everything else: + * the signed endpoints answer 503 via {@link #requireConfigured()}, enrollment hands out an + * empty key list, and a single WARN at startup says what is missing. A key that is + * configured but malformed is a startup failure instead, because silently running unsigned + * would look exactly like "not configured" and hide the typo. + */ +@Service +@Slf4j +public class ContentSigningService { + + /** Domain separation prefix for package signatures; see section 4.2. */ + public static final String MBU_SIGNATURE_PREFIX = "musallahboard-mbu-v2\n"; + + private static final int SEED_LEN = 32; + private static final int RAW_PUBLIC_KEY_LEN = 32; + + /** + * DER prefix of a PKCS#8 PrivateKeyInfo for Ed25519 (RFC 8410): version 0, the Ed25519 + * AlgorithmIdentifier, and an OCTET STRING wrapping the 32-byte seed as another OCTET + * STRING. Appending the seed gives a complete encoding the JDK's KeyFactory accepts. + */ + private static final byte[] PKCS8_PREFIX = HexFormat.of().parseHex("302e020100300506032b657004220420"); + + /** DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw key is the 32 bytes after it. */ + private static final byte[] X509_PREFIX = HexFormat.of().parseHex("302a300506032b6570032100"); + + /** The configured signing key, or null when signing is not configured. */ + private final SigningKey current; + + /** Every public content key to publish, current first. Empty when not configured. */ + private final List publishedKeys; + + private record SigningKey(PrivateKey privateKey, byte[] rawPublicKey, String keyId) {} + + /** + * @param privateKeyBase64 base64 of the 32-byte seed; empty or blank disables signing. + * Falls back to the {@code MUSALLAHBOARD_CONTENT_SIGNING_KEY} + * environment variable so a deployment that only sets the + * variable needs no properties change. + * @param previousPublicKeysCsv comma-separated base64 raw public keys of retired content + * keys, still published so boards keep trusting packages + * they already hold during a rotation + * @throws IllegalStateException if either value is present but malformed + */ + public ContentSigningService( + @Value("${musallahboard.content-signing.private-key:${MUSALLAHBOARD_CONTENT_SIGNING_KEY:}}") + String privateKeyBase64, + @Value("${musallahboard.content-signing.previous-public-keys:}") + String previousPublicKeysCsv) { + this.current = isBlank(privateKeyBase64) ? null : loadSigningKey(privateKeyBase64.trim()); + + if (current == null) { + this.publishedKeys = List.of(); + log.warn("Content signing is not configured (musallahboard.content-signing.private-key / " + + "MUSALLAHBOARD_CONTENT_SIGNING_KEY). Board content packages cannot be built: " + + "POST /api/agent/content-bundle and the admin offline-bundle download will answer 503, " + + "and newly enrolled boards receive no content key."); + if (!isBlank(previousPublicKeysCsv)) { + log.warn("musallahboard.content-signing.previous-public-keys is set but ignored " + + "because no current content signing key is configured."); + } + return; + } + + // Keyed by id so a previous key that repeats the current one (or another) is listed once. + Map keys = new LinkedHashMap<>(); + keys.put(current.keyId(), view(current.rawPublicKey())); + for (byte[] previous : parsePreviousKeys(previousPublicKeysCsv)) { + SigningKeyView v = view(previous); + keys.putIfAbsent(v.getKeyId(), v); + } + this.publishedKeys = List.copyOf(keys.values()); + log.info("Content signing key {} loaded; publishing {} content key(s)", current.keyId(), publishedKeys.size()); + } + + /** True when a signing key is configured. */ + public boolean isConfigured() { + return current != null; + } + + /** + * Throws the 503 every signed endpoint returns when no key is configured. Call it before + * doing any expensive work, so a misconfigured server fails fast and says why. + */ + public void requireConfigured() { + if (current == null) { + throw new ApiResponseException(HttpStatus.SERVICE_UNAVAILABLE, + ErrorResponse.of("Content signing is not configured on this server " + + "(set musallahboard.content-signing.private-key / MUSALLAHBOARD_CONTENT_SIGNING_KEY)")); + } + } + + /** Key id of the current signing key; null when not configured. */ + public String currentKeyId() { + return current == null ? null : current.keyId(); + } + + /** + * The public content keys boards should trust, current first, then any previous keys. + * Empty when signing is not configured. Returned by enrollment and by + * {@code GET /api/agent/signing-keys}. + */ + public List publicKeys() { + return publishedKeys; + } + + /** + * Signs {@code manifestBytes} as an {@code mbu.json}: the signature covers the ASCII + * prefix {@value #MBU_SIGNATURE_PREFIX} (with its newline) followed by exactly these + * bytes. The caller must store these same bytes in the zip; re-serializing the manifest + * afterwards would produce bytes the signature does not cover. + * + * @return the 64-byte Ed25519 signature + * @throws ApiResponseException 503 when signing is not configured + */ + public byte[] signManifest(byte[] manifestBytes) { + requireConfigured(); + return sign(current.privateKey(), mbuSignedMessage(manifestBytes)); + } + + /** The exact message a package signature covers: the domain prefix, then the manifest bytes. */ + public static byte[] mbuSignedMessage(byte[] manifestBytes) { + byte[] prefix = MBU_SIGNATURE_PREFIX.getBytes(StandardCharsets.US_ASCII); + byte[] message = new byte[prefix.length + manifestBytes.length]; + System.arraycopy(prefix, 0, message, 0, prefix.length); + System.arraycopy(manifestBytes, 0, message, prefix.length, manifestBytes.length); + return message; + } + + /** Lowercase hex of the first 8 bytes of SHA-256 over a raw 32-byte public key. */ + public static String keyId(byte[] rawPublicKey) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawPublicKey); + return HexFormat.of().formatHex(digest, 0, 8); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is mandatory in every JRE", e); + } + } + + /** + * Derives the raw 32-byte public key for a 32-byte seed. + *

+ * The JDK has no public API for "public key of this private key". Its Ed25519 key pair + * generator, though, takes the seed as the first 32 bytes it reads from the supplied + * {@link SecureRandom}, so feeding it the seed reproduces the pair. The constructor then + * proves the pair matches by signing and verifying a probe message, so a JDK that ever + * drew its randomness differently would fail loudly at startup rather than publish a + * public key that does not match the signatures. + */ + static byte[] rawPublicKeyForSeed(byte[] seed) throws GeneralSecurityException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); + generator.initialize(NamedParameterSpec.ED25519, new FixedSeedRandom(seed)); + KeyPair pair = generator.generateKeyPair(); + return rawPublicKey(pair.getPublic()); + } + + /** The raw 32-byte key out of an Ed25519 public key's X.509 encoding. */ + static byte[] rawPublicKey(PublicKey publicKey) { + byte[] encoded = publicKey.getEncoded(); + if (encoded == null || encoded.length != X509_PREFIX.length + RAW_PUBLIC_KEY_LEN + || !Arrays.equals(encoded, 0, X509_PREFIX.length, X509_PREFIX, 0, X509_PREFIX.length)) { + throw new IllegalStateException("Unexpected Ed25519 public key encoding"); + } + return Arrays.copyOfRange(encoded, X509_PREFIX.length, encoded.length); + } + + /** Wraps a 32-byte seed in the PKCS#8 envelope and decodes it with the JDK provider. */ + static PrivateKey privateKeyForSeed(byte[] seed) throws GeneralSecurityException { + byte[] pkcs8 = new byte[PKCS8_PREFIX.length + seed.length]; + System.arraycopy(PKCS8_PREFIX, 0, pkcs8, 0, PKCS8_PREFIX.length); + System.arraycopy(seed, 0, pkcs8, PKCS8_PREFIX.length, seed.length); + return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(pkcs8)); + } + + // ==================== Loading ==================== + + private static SigningKey loadSigningKey(String base64) { + byte[] seed; + try { + seed = Base64.getDecoder().decode(base64); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "musallahboard.content-signing.private-key is not valid base64", e); + } + if (seed.length != SEED_LEN) { + throw new IllegalStateException("musallahboard.content-signing.private-key must be the base64 of a " + + SEED_LEN + "-byte Ed25519 seed, got " + seed.length + " bytes"); + } + try { + PrivateKey privateKey = privateKeyForSeed(seed); + byte[] rawPublicKey = rawPublicKeyForSeed(seed); + // Prove the derived public key belongs to this private key before publishing it. + byte[] probe = "musallahboard content key self-test".getBytes(StandardCharsets.US_ASCII); + if (!verify(rawPublicKey, probe, sign(privateKey, probe))) { + throw new IllegalStateException("Derived content public key does not match the private key"); + } + return new SigningKey(privateKey, rawPublicKey, keyId(rawPublicKey)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not load the content signing key: " + e.getMessage(), e); + } finally { + Arrays.fill(seed, (byte) 0); + } + } + + private static List parsePreviousKeys(String csv) { + List keys = new ArrayList<>(); + if (isBlank(csv)) return keys; + for (String part : csv.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) continue; + byte[] raw; + try { + raw = Base64.getDecoder().decode(trimmed); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "musallahboard.content-signing.previous-public-keys holds a value that is not base64: " + trimmed, e); + } + if (raw.length != RAW_PUBLIC_KEY_LEN) { + throw new IllegalStateException("musallahboard.content-signing.previous-public-keys must hold base64 raw " + + RAW_PUBLIC_KEY_LEN + "-byte Ed25519 public keys, got " + raw.length + " bytes: " + trimmed); + } + keys.add(raw); + } + return keys; + } + + private static SigningKeyView view(byte[] rawPublicKey) { + return new SigningKeyView(keyId(rawPublicKey), Base64.getEncoder().encodeToString(rawPublicKey)); + } + + // ==================== Primitives ==================== + + private static byte[] sign(PrivateKey privateKey, byte[] message) { + try { + // Signature objects are not thread-safe; one per call is cheap for Ed25519. + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(privateKey); + signer.update(message); + return signer.sign(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Ed25519 signing failed", e); + } + } + + private static boolean verify(byte[] rawPublicKey, byte[] message, byte[] signature) + throws GeneralSecurityException { + byte[] x509 = new byte[X509_PREFIX.length + rawPublicKey.length]; + System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.length); + System.arraycopy(rawPublicKey, 0, x509, X509_PREFIX.length, rawPublicKey.length); + PublicKey publicKey = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(x509)); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(message); + return verifier.verify(signature); + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + /** + * A {@link SecureRandom} that yields the given seed and then refuses to produce more, so + * {@link #rawPublicKeyForSeed} fails instead of silently mixing in other bytes if the + * generator ever asks for more than one seed's worth. + */ + private static final class FixedSeedRandom extends SecureRandom { + private final byte[] seed; + private boolean used; + + FixedSeedRandom(byte[] seed) { + this.seed = seed.clone(); + } + + @Override + public void nextBytes(byte[] bytes) { + if (used || bytes.length != seed.length) { + throw new IllegalStateException("Ed25519 key generator requested unexpected randomness"); + } + System.arraycopy(seed, 0, bytes, 0, seed.length); + Arrays.fill(seed, (byte) 0); + used = true; + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java new file mode 100644 index 0000000..65f9bcd --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java @@ -0,0 +1,59 @@ +package com.ibrasoft.lensbridge.service.board.offline; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import java.util.List; + +/** + * {@code mbu.json} of a {@code content} package, format version 2. This is a wire contract + * with the board's agent (MusallahBoard {@code agent/docs/architecture.md}, section 4.1), so + * every field is spelled out as the exact JSON type the contract names: timestamps and dates + * are plain strings, not whatever the ObjectMapper's date settings happen to produce. + *

+ * The serialized bytes of this record are what gets signed. They are produced once, signed, + * and stored in the zip as-is; nothing may re-serialize the manifest after signing. + * {@link JsonInclude.Include#ALWAYS} keeps {@code "version": null} in the output even if the + * application mapper is ever configured to drop nulls, so the bytes do not depend on that. + * + * @param format always {@code "mbu"} + * @param formatVersion always {@value OfflineBundleService#FORMAT_VERSION} + * @param type always {@code "content"}: this backend only holds a content key + * @param createdAt RFC 3339 UTC, second precision ({@code 2026-09-24T14:02:11Z}) + * @param sequence {@code createdAt} in epoch milliseconds; the board's anti-rollback counter + * @param deviceId the device the content is bound to + * @param version always null for content (only software packages carry a version) + * @param files every payload and media file, including media left out of this zip + */ +@JsonInclude(JsonInclude.Include.ALWAYS) +@JsonPropertyOrder({"format", "formatVersion", "type", "createdAt", "sequence", "deviceId", "version", "files", "content"}) +public record MbuManifest( + String format, + int formatVersion, + String type, + String createdAt, + long sequence, + String deviceId, + String version, + List files, + Content content +) { + + /** One file of the package. {@code sha256} is lowercase hex of the bytes, {@code bytes} their exact size. */ + @JsonPropertyOrder({"path", "sha256", "bytes"}) + public record FileEntry(String path, String sha256, long bytes) {} + + /** + * The per-type block of a content package. + * + * @param timezone IANA zone the days were cut in + * @param firstDay ISO date, inclusive + * @param lastDay ISO date, inclusive + * @param media every {@code media/} file with the type the board serves it as + */ + @JsonPropertyOrder({"timezone", "firstDay", "lastDay", "media"}) + public record Content(String timezone, String firstDay, String lastDay, List media) {} + + @JsonPropertyOrder({"path", "contentType"}) + public record MediaEntry(String path, String contentType) {} +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCache.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCache.java new file mode 100644 index 0000000..507c1e4 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCache.java @@ -0,0 +1,76 @@ +package com.ibrasoft.lensbridge.service.board.offline; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Remembers, per storage object key, what a poster image turned into inside a package: its + * SHA-256, bundle path, size and content type. + *

+ * This is what makes online sync cheap. A board syncs every 30 minutes and after every + * refresh, and almost always already holds every poster; it says so in {@code haveMedia}. + * The manifest must still list those files with their hash and size, and without this cache + * the only way to learn the hash is to download the image from R2 again. With it, a sync in + * which the board has all media downloads nothing at all. + *

+ * Bounded (least recently used entries go first) and short-lived. Poster images are + * uploaded under fresh keys, so an entry going stale needs someone to overwrite an object in + * place; the one-hour TTL caps how long a manifest could then describe the old bytes. Even + * that is safe on the board: a file that is actually shipped is always downloaded and hashed + * afresh (and refreshes the entry), and a listed file the board lacks makes it reject the + * package rather than show something unsigned. + */ +final class MediaMetadataCache { + + static final int DEFAULT_MAX_ENTRIES = 1024; + static final Duration DEFAULT_TTL = Duration.ofHours(1); + + /** What a fetched image became; everything the manifest needs except the bytes themselves. */ + record MediaMetadata(String path, String sha256, long bytes, String contentType) {} + + private record Timestamped(MediaMetadata metadata, Instant storedAt) {} + + private final int maxEntries; + private final Duration ttl; + private final Clock clock; + private final LinkedHashMap entries; + + MediaMetadataCache(Clock clock) { + this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, clock); + } + + MediaMetadataCache(int maxEntries, Duration ttl, Clock clock) { + this.maxEntries = maxEntries; + this.ttl = ttl; + this.clock = clock; + // Access order, so get() refreshes recency and the eldest entry is the least recently used. + this.entries = new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MediaMetadataCache.this.maxEntries; + } + }; + } + + /** The cached metadata for {@code objectKey}, or null if absent or older than the TTL. */ + synchronized MediaMetadata get(String objectKey) { + Timestamped entry = entries.get(objectKey); + if (entry == null) return null; + if (entry.storedAt().plus(ttl).isBefore(clock.instant())) { + entries.remove(objectKey); + return null; + } + return entry.metadata(); + } + + synchronized void put(String objectKey, MediaMetadata metadata) { + entries.put(objectKey, new Timestamped(metadata, clock.instant())); + } + + synchronized int size() { + return entries.size(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundle.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundle.java index ea32ed7..d817b99 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundle.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundle.java @@ -8,7 +8,8 @@ import java.util.zip.ZipOutputStream; /** - * A fully built offline bundle, held in memory until it is written. + * A fully built, signed content package ({@code .mbu}), held in memory until it is written. + * The name predates the package format: the same package now also serves online sync. *

* Everything that can fail — assembly, image downloads, serialization — has already happened * by the time one of these exists, so {@link #writeTo} can only fail on the transport. That @@ -18,7 +19,8 @@ public record OfflineBundle(String filename, List entries) { /** * @param stored true for content that is already compressed (images), which gains - * nothing from deflate + * nothing from deflate. JSON entries ({@code mbu.json}, {@code mbu.sig}, + * payloads) are deflated. */ public record Entry(String name, byte[] content, boolean stored) {} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleManifest.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleManifest.java deleted file mode 100644 index 81a7a76..0000000 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleManifest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ibrasoft.lensbridge.service.board.offline; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - -import java.util.List; -import java.util.UUID; - -/** - * {@code manifest.json} inside an offline bundle. This is a wire contract with the board's - * agent (see MusallahBoard {@code agent/docs/offline.md}, "Contract 1"), so dates are plain - * strings in the exact format the contract names rather than whatever the ObjectMapper's - * date settings happen to produce. - * - * @param generatedAt ISO-8601 UTC instant, second precision ({@code 2026-09-24T14:02:11Z}) - * @param firstDay ISO date, inclusive - * @param lastDay ISO date, inclusive - */ -@JsonPropertyOrder({"formatVersion", "deviceId", "timezone", "generatedAt", "firstDay", "lastDay", "media"}) -public record OfflineBundleManifest( - int formatVersion, - UUID deviceId, - String timezone, - String generatedAt, - String firstDay, - String lastDay, - List media -) { - - /** One file under {@code media/}. {@code sha256} is lowercase hex and the file's base name. */ - @JsonPropertyOrder({"path", "sha256", "bytes", "contentType"}) - public record MediaEntry(String path, String sha256, long bytes, String contentType) {} -} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java index fbc715a..f59bcca 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.service.board.offline; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; @@ -24,34 +25,52 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Base64; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; /** - * Builds the zip an admin carries to a board that has no internet: a manifest, one payload - * per day, and every poster image those payloads reference. The format is a contract with the - * board's agent — see MusallahBoard {@code agent/docs/offline.md}, "Contract 1". + * Builds signed {@code content} packages ({@code .mbu}, format version 2) for a board: a + * manifest, its signature, one payload per day, and the poster images those payloads + * reference. The format is a contract with the board's agent; see MusallahBoard + * {@code agent/docs/architecture.md}, section 4. + *

+ * The same package serves every transport. An admin downloads it for a board with no + * internet (all media included), and an online board fetches it itself through + * {@code POST /api/agent/content-bundle}, naming the media it already holds so those files + * are left out of the zip. Left-out files stay listed and signed in {@code mbu.json}, so the + * board can check its own copies against them. *

* Each day's payload comes from {@link BoardPayloadAssembler#assembleForDay}, so no frame - * logic lives here. This class only fetches images, rewrites their URLs to bundle paths and - * packs the result. + * logic lives here. This class only fetches images, rewrites their URLs to package paths, + * writes the manifest, signs it and packs the result. + *

+ * The whole package is built in memory before a byte is written. It is a few days of JSON + * plus a handful of posters (10 MB cap each), and building first is the only way a failed + * image download can still become a 502 rather than a truncated download. *

- * The whole bundle is built in memory before a byte is written. A bundle is a few days of - * JSON plus a handful of posters (10 MB cap each), and building first is the only way a - * failed image download can still become a 502 rather than a truncated download. + * Signing: the manifest is serialized exactly once. Those bytes are signed and those same + * bytes are stored as {@code mbu.json}; the signature covers + * {@value ContentSigningService#MBU_SIGNATURE_PREFIX} followed by them, and nothing else. */ @Service @Slf4j public class OfflineBundleService { - public static final int FORMAT_VERSION = 1; + public static final int FORMAT_VERSION = 2; public static final int DEFAULT_DAYS = 14; public static final int MAX_DAYS = 31; + /** Media type of a {@code .mbu} package. */ + public static final String MBU_CONTENT_TYPE = "application/vnd.musallahboard.mbu"; + + private static final String MANIFEST_ENTRY = "mbu.json"; + private static final String SIGNATURE_ENTRY = "mbu.sig"; private static final String MEDIA_DIR = "media/"; private static final String PAYLOAD_DIR = "payloads/"; @@ -73,39 +92,68 @@ public class OfflineBundleService { "avif", "image/avif", "svg", "image/svg+xml"); + /** + * The only content types the agent accepts in {@code content.media}. Anything else that + * storage reports (an uploaded HEIC, say) is listed as {@code application/octet-stream} + * rather than making the board reject the whole package. + */ + private static final String OCTET_STREAM = "application/octet-stream"; + private final BoardPayloadAssembler payloadAssembler; private final DeviceRepository deviceRepository; private final R2StorageService r2StorageService; /** Spring's own mapper, so payload files serialize exactly like {@code /api/musallah/payload}. */ private final ObjectMapper objectMapper; + private final ContentSigningService signingService; private final Clock clock; + /** Lets a sync in which the board already holds every poster skip downloading them. */ + private final MediaMetadataCache mediaMetadataCache; @Autowired public OfflineBundleService(BoardPayloadAssembler payloadAssembler, DeviceRepository deviceRepository, R2StorageService r2StorageService, - ObjectMapper objectMapper) { - this(payloadAssembler, deviceRepository, r2StorageService, objectMapper, Clock.systemUTC()); + ObjectMapper objectMapper, + ContentSigningService signingService) { + this(payloadAssembler, deviceRepository, r2StorageService, objectMapper, signingService, + Clock.systemUTC()); } OfflineBundleService(BoardPayloadAssembler payloadAssembler, DeviceRepository deviceRepository, R2StorageService r2StorageService, ObjectMapper objectMapper, + ContentSigningService signingService, Clock clock) { this.payloadAssembler = payloadAssembler; this.deviceRepository = deviceRepository; this.r2StorageService = r2StorageService; this.objectMapper = objectMapper; + this.signingService = signingService; this.clock = clock; + this.mediaMetadataCache = new MediaMetadataCache(clock); } /** - * @param days number of days starting today in the device's timezone, 1–{@value #MAX_DAYS} - * @throws ApiResponseException 400 for a bad {@code days}, 404 for an unknown device, 409 - * for a revoked one, 502 when a poster image cannot be fetched + * A package with every media file included, as an admin downloads it for a board with no + * internet. + * + * @see #build(UUID, int, Set) */ public OfflineBundle build(UUID deviceId, int days) { + return build(deviceId, days, Set.of()); + } + + /** + * @param days number of days starting today in the device's timezone, 1-{@value #MAX_DAYS} + * @param haveMedia SHA-256 (lowercase hex) of media the board already holds. Matching files + * are listed in {@code mbu.json} but left out of the zip, and are not + * downloaded from storage when their hash is already known. + * @throws ApiResponseException 400 for a bad {@code days}, 404 for an unknown device, 409 + * for a revoked one, 503 when no content signing key is + * configured, 502 when a poster image cannot be fetched + */ + public OfflineBundle build(UUID deviceId, int days, Set haveMedia) { if (days < 1 || days > MAX_DAYS) { throw new ApiResponseException(HttpStatus.BAD_REQUEST, ErrorResponse.of("days must be between 1 and " + MAX_DAYS)); @@ -118,10 +166,12 @@ public OfflineBundle build(UUID deviceId, int days) { throw new ApiResponseException(HttpStatus.CONFLICT, ErrorResponse.of("Device is revoked")); } + // Before any assembly or download: without a key the result could not be signed anyway. + signingService.requireConfigured(); ZoneId zone = payloadAssembler.zoneFor(device); - Instant generatedAt = clock.instant().truncatedTo(ChronoUnit.SECONDS); - LocalDate firstDay = LocalDate.ofInstant(generatedAt, zone); + Instant createdAt = clock.instant().truncatedTo(ChronoUnit.SECONDS); + LocalDate firstDay = LocalDate.ofInstant(createdAt, zone); LocalDate lastDay = firstDay.plusDays(days - 1L); Map payloads = new LinkedHashMap<>(); @@ -129,7 +179,7 @@ public OfflineBundle build(UUID deviceId, int days) { payloads.put(day, payloadAssembler.assembleForDay(device, day)); } - // A poster running all fortnight appears in every payload; download it once. Two + // A poster running all fortnight appears in every payload; resolve it once. Two // posters sharing identical bytes under different keys are stored once too. Map mediaByHash = new LinkedHashMap<>(); Map mediaByUrl = new LinkedHashMap<>(); @@ -138,53 +188,83 @@ public OfflineBundle build(UUID deviceId, int days) { String url = poster.config().getPosterUrl(); MediaFile media = url == null ? null : mediaByUrl.get(url); if (media == null) { - MediaFile fetched = fetch(poster); - media = mediaByHash.computeIfAbsent(fetched.sha256(), sha -> fetched); + MediaFile resolved = resolve(poster, haveMedia); + media = mediaByHash.computeIfAbsent(resolved.sha256(), sha -> resolved); mediaByUrl.put(url, media); } poster.config().setPosterUrl("/" + media.path()); } } - List entries = new ArrayList<>(); - entries.add(new OfflineBundle.Entry("manifest.json", - toJson(manifest(deviceId, zone, generatedAt, firstDay, lastDay, mediaByHash.values())), - false)); - payloads.forEach((day, payload) -> - entries.add(new OfflineBundle.Entry(PAYLOAD_DIR + day + ".json", toJson(payload), false))); - mediaByHash.values().forEach(media -> - entries.add(new OfflineBundle.Entry(media.path(), media.content(), true))); - - log.info("Built offline bundle for device {}: {}..{}, {} media file(s)", - deviceId, firstDay, lastDay, mediaByHash.size()); - - String filename = "musallahboard-" + deviceId.toString().substring(0, 8) + "-" + firstDay + ".zip"; - return new OfflineBundle(filename, entries); - } + // Payload bytes first: the manifest lists their hashes. + Map payloadFiles = new LinkedHashMap<>(); + payloads.forEach((day, payload) -> payloadFiles.put(PAYLOAD_DIR + day + ".json", toJson(payload))); - private static OfflineBundleManifest manifest(UUID deviceId, ZoneId zone, Instant generatedAt, - LocalDate firstDay, LocalDate lastDay, - Iterable media) { - List entries = new ArrayList<>(); - for (MediaFile m : media) { - entries.add(new OfflineBundleManifest.MediaEntry( - m.path(), m.sha256(), m.content().length, m.contentType())); + List files = new ArrayList<>(); + payloadFiles.forEach((path, bytes) -> files.add(new MbuManifest.FileEntry(path, sha256Hex(bytes), bytes.length))); + List mediaEntries = new ArrayList<>(); + for (MediaFile m : mediaByHash.values()) { + files.add(new MbuManifest.FileEntry(m.path(), m.sha256(), m.bytes())); + mediaEntries.add(new MbuManifest.MediaEntry(m.path(), m.contentType())); } - return new OfflineBundleManifest( + + MbuManifest manifest = new MbuManifest( + "mbu", FORMAT_VERSION, - deviceId, - zone.getId(), - generatedAt.toString(), - firstDay.toString(), - lastDay.toString(), - entries); + "content", + createdAt.toString(), + createdAt.toEpochMilli(), + deviceId.toString(), + null, + files, + new MbuManifest.Content(zone.getId(), firstDay.toString(), lastDay.toString(), mediaEntries)); + + // Serialize once; sign these bytes; store these same bytes. + byte[] manifestBytes = toJson(manifest); + byte[] signature = signingService.signManifest(manifestBytes); + byte[] signatureFile = toJson(new SignatureFile(List.of( + new SignatureEntry(signingService.currentKeyId(), Base64.getEncoder().encodeToString(signature))))); + + List entries = new ArrayList<>(); + entries.add(new OfflineBundle.Entry(MANIFEST_ENTRY, manifestBytes, false)); + entries.add(new OfflineBundle.Entry(SIGNATURE_ENTRY, signatureFile, false)); + payloadFiles.forEach((path, bytes) -> entries.add(new OfflineBundle.Entry(path, bytes, false))); + int omitted = 0; + for (MediaFile media : mediaByHash.values()) { + if (media.content() == null) { + omitted++; + } else { + entries.add(new OfflineBundle.Entry(media.path(), media.content(), true)); + } + } + + log.info("Built content package {} for device {}: {}..{}, {} media file(s), {} left out as already on the board", + manifest.sequence(), deviceId, firstDay, lastDay, mediaByHash.size(), omitted); + + String filename = "musallahboard-content-" + deviceId.toString().substring(0, 8) + "-" + firstDay + ".mbu"; + return new OfflineBundle(filename, entries); } + /** {@code mbu.sig}: {@code {"signatures":[{"keyId","sig"}]}}. */ + private record SignatureFile(List signatures) {} + + @JsonPropertyOrder({"keyId", "sig"}) + private record SignatureEntry(String keyId, String sig) {} + // ==================== Media ==================== private record PosterFrame(String frameId, PosterFrameConfig config) {} - private record MediaFile(String path, String sha256, byte[] content, String contentType) {} + /** + * @param content the image bytes, or null when the board already holds this file and it + * is only listed in the manifest + */ + private record MediaFile(String path, String sha256, long bytes, String contentType, byte[] content) { + + static MediaFile listedOnly(MediaMetadataCache.MediaMetadata metadata) { + return new MediaFile(metadata.path(), metadata.sha256(), metadata.bytes(), metadata.contentType(), null); + } + } private static List posterFramesOf(MusallahBoardPayload payload) { List posters = new ArrayList<>(); @@ -197,8 +277,25 @@ private static List posterFramesOf(MusallahBoardPayload payload) { return posters; } - /** Downloads one poster's image. Any failure is a 502 naming the poster. */ - private MediaFile fetch(PosterFrame poster) { + /** + * The media file for one poster. When the board already holds it and its hash is cached, + * nothing is downloaded; otherwise the image is fetched (which also refreshes the cache) + * and its bytes are kept only if the board lacks them. + */ + private MediaFile resolve(PosterFrame poster, Set haveMedia) { + String objectKey = objectKeyOf(poster); + MediaMetadataCache.MediaMetadata cached = mediaMetadataCache.get(objectKey); + if (cached != null && haveMedia.contains(cached.sha256())) { + return MediaFile.listedOnly(cached); + } + MediaFile fetched = fetch(poster, objectKey); + return haveMedia.contains(fetched.sha256()) + ? new MediaFile(fetched.path(), fetched.sha256(), fetched.bytes(), fetched.contentType(), null) + : fetched; + } + + /** The storage key behind a poster's image URL. Anything unusable is a 502 naming the poster. */ + private String objectKeyOf(PosterFrame poster) { String url = poster.config().getPosterUrl(); if (url == null || url.isBlank()) { throw imageFailure(poster, "it has no image"); @@ -208,12 +305,16 @@ private MediaFile fetch(PosterFrame poster) { if (objectKey == null || objectKey.isBlank()) { throw imageFailure(poster, "its image URL is not a storage object: " + url); } + return objectKey; + } + /** Downloads one poster's image and records what it became. Any failure is a 502 naming the poster. */ + private MediaFile fetch(PosterFrame poster, String objectKey) { R2StorageService.R2Object object; try { object = r2StorageService.getObject(objectKey); } catch (Exception e) { - log.error("Offline bundle: failed to fetch image {} for {}", objectKey, poster.frameId(), e); + log.error("Content package: failed to fetch image {} for {}", objectKey, poster.frameId(), e); throw imageFailure(poster, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); } if (object == null || object.bytes() == null || object.bytes().length == 0) { @@ -228,13 +329,14 @@ private MediaFile fetch(PosterFrame poster) { String extension = contentType == null ? null : EXTENSION_BY_CONTENT_TYPE.get(contentType); if (extension == null) { extension = keyExtension != null ? keyExtension : "bin"; - String byExtension = CONTENT_TYPE_BY_EXTENSION.get(extension); - if (byExtension != null || contentType == null) { - contentType = byExtension != null ? byExtension : "application/octet-stream"; - } + // Whatever storage said, the manifest may only name a type the agent accepts. + contentType = CONTENT_TYPE_BY_EXTENSION.getOrDefault(extension, OCTET_STREAM); } - return new MediaFile(MEDIA_DIR + sha256 + "." + extension, sha256, object.bytes(), contentType); + MediaMetadataCache.MediaMetadata metadata = new MediaMetadataCache.MediaMetadata( + MEDIA_DIR + sha256 + "." + extension, sha256, object.bytes().length, contentType); + mediaMetadataCache.put(objectKey, metadata); + return new MediaFile(metadata.path(), sha256, metadata.bytes(), contentType, object.bytes()); } private static ApiResponseException imageFailure(PosterFrame poster, String reason) { @@ -254,13 +356,17 @@ private static String normalizeContentType(String contentType) { return bare.trim().toLowerCase(Locale.ROOT); } - /** The key's extension if it is a plausible one (short, alphanumeric), lowercased. */ + /** + * The key's extension if it is a plausible one (at most five ASCII letters or digits), + * lowercased. ASCII only: the agent requires media extensions to match {@code [a-z0-9]{1,10}}. + */ private static String extensionOf(String objectKey) { int slash = objectKey.lastIndexOf('/'); int dot = objectKey.lastIndexOf('.'); if (dot <= slash + 1 || dot == objectKey.length() - 1) return null; String ext = objectKey.substring(dot + 1).toLowerCase(Locale.ROOT); - return ext.length() <= 5 && ext.chars().allMatch(Character::isLetterOrDigit) ? ext : null; + return ext.length() <= 5 && ext.chars().allMatch(c -> (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) + ? ext : null; } private static String sha256Hex(byte[] bytes) { @@ -276,7 +382,7 @@ private byte[] toJson(Object value) { return objectMapper.writeValueAsBytes(value); } catch (JsonProcessingException e) { throw new ApiResponseException(HttpStatus.INTERNAL_SERVER_ERROR, - ErrorResponse.of("Could not serialize offline bundle: " + e.getOriginalMessage())); + ErrorResponse.of("Could not serialize content package: " + e.getOriginalMessage())); } } } diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example index ce443ec..0000d53 100644 --- a/src/main/resources/application.properties.example +++ b/src/main/resources/application.properties.example @@ -45,6 +45,15 @@ musallahboard.enrollment.tokenPepper= musallahboard.defaultTimezone=America/Toronto # How often undelivered device commands are expired and in-flight ones timed out. musallahboard.command.reaperIntervalMs=60000 +# Content signing key for board packages (.mbu): base64 of a 32-byte Ed25519 seed, e.g. from +# `mbpack keygen` in the agent repo. Boards pin the matching public key at enrollment, and only +# accept content signed by a pinned key. Optional: without it the app still starts, but +# POST /api/agent/content-bundle and the admin offline-bundle download answer 503. Keep it out of +# this file in production and set the MUSALLAHBOARD_CONTENT_SIGNING_KEY environment variable. +musallahboard.content-signing.private-key=${MUSALLAHBOARD_CONTENT_SIGNING_KEY:} +# During a key rotation: comma-separated base64 raw public keys of retired content keys, still +# published by GET /api/agent/signing-keys so boards keep trusting what they already hold. +musallahboard.content-signing.previous-public-keys= # Cloudflare R2 cloudflare.r2.endpoint=https://.r2.cloudflarestorage.com diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java new file mode 100644 index 0000000..de70a68 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java @@ -0,0 +1,281 @@ +package com.ibrasoft.lensbridge.controller; + +import com.ibrasoft.lensbridge.dto.board.response.SigningKeyView; +import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.UserService; +import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; +import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator; +import com.ibrasoft.lensbridge.service.board.offline.ContentSigningService; +import com.ibrasoft.lensbridge.service.board.offline.OfflineBundle; +import com.ibrasoft.lensbridge.service.board.offline.OfflineBundleService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipInputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * {@code POST /api/agent/content-bundle} and {@code GET /api/agent/signing-keys}, through + * MockMvc with the real {@link DeviceRequestAuthenticator}: requests are signed with a real + * device key exactly as the agent signs them. + *

+ * Filters are off: both paths are {@code permitAll} in production, so the security chain has + * no say, and authentication is the controller's own job, which is what this exercises. + */ +@WebMvcTest(controllers = {AgentContentController.class, AgentEnrollmentController.class}) +@AutoConfigureMockMvc(addFilters = false) +@Import({DeviceRequestAuthenticator.class, Ed25519Verifier.class}) +@TestPropertySource(properties = "musallahboard.agent.websocketUrl=wss://example.test/api/agent/ws") +class AgentContentControllerTest { + + private static final UUID DEVICE_ID = UUID.fromString("3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"); + private static final String URL = "/api/agent/content-bundle"; + private static final String SHA_A = "a".repeat(64); + private static final String SHA_B = "0123456789abcdef".repeat(4); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OfflineBundleService offlineBundleService; + @MockitoBean + private ContentSigningService signingService; + @MockitoBean + private DeviceRepository deviceRepository; + @MockitoBean + private DeviceEnrollmentService enrollmentService; + /** Needed by WebConfig's CurrentUserArgumentResolver; see BoardAdminControllerPermissionTest. */ + @MockitoBean + private UserService userService; + + private KeyPair keys; + private Device device; + + @BeforeEach + void setUp() { + keys = Ed25519TestUtil.generate(); + device = Device.builder() + .id(DEVICE_ID) + .displayName("Lobby board") + .audience(Audience.BROTHERS) + .publicKey(Ed25519TestUtil.rawPublicKey(keys.getPublic())) + .build(); + when(deviceRepository.findById(DEVICE_ID)).thenReturn(Optional.of(device)); + when(offlineBundleService.build(any(), anyInt(), any())).thenReturn(bundle()); + } + + private static OfflineBundle bundle() { + return new OfflineBundle("musallahboard-content-3f2a1b4c-2026-09-24.mbu", List.of( + new OfflineBundle.Entry("mbu.json", "{}".getBytes(StandardCharsets.UTF_8), false), + new OfflineBundle.Entry("mbu.sig", "{}".getBytes(StandardCharsets.UTF_8), false))); + } + + /** POST {@code body} to {@code path}, signed over {@code signedBody} (normally the same bytes). */ + private MockHttpServletRequestBuilder signedPost(String path, String signedPath, byte[] body, byte[] signedBody, + long timestamp) { + byte[] message = DeviceRequestAuthenticator.message("POST", signedPath, DEVICE_ID, timestamp, signedBody); + return post(path) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header(DeviceRequestAuthenticator.DEVICE_ID_HEADER, DEVICE_ID.toString()) + .header(DeviceRequestAuthenticator.TIMESTAMP_HEADER, Long.toString(timestamp)) + .header(DeviceRequestAuthenticator.SIGNATURE_HEADER, + Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(keys.getPrivate(), message))); + } + + private MockHttpServletRequestBuilder signedPost(String json) { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + return signedPost(URL, URL, body, body, System.currentTimeMillis()); + } + + // ==================== content-bundle ==================== + + @Test + void aSignedRequestGetsThePackageBuiltWithItsDaysAndHaveMedia() throws Exception { + MvcResult result = mockMvc.perform(signedPost( + "{\"days\": 10, \"haveMedia\": [\"" + SHA_A + "\", \"" + SHA_B + "\"], \"future\": true}")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/vnd.musallahboard.mbu")) + .andExpect(header().string("Content-Disposition", + "attachment; filename=\"musallahboard-content-3f2a1b4c-2026-09-24.mbu\"")) + .andReturn(); + + verify(offlineBundleService).build(DEVICE_ID, 10, Set.of(SHA_A, SHA_B)); + List names = new ArrayList<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(result.getResponse().getContentAsByteArray()))) { + for (var e = zip.getNextEntry(); e != null; e = zip.getNextEntry()) names.add(e.getName()); + } + assertThat(names).containsExactly("mbu.json", "mbu.sig"); + } + + @Test + void anEmptyBodyMeansSevenDaysAndNoMedia() throws Exception { + mockMvc.perform(signedPost(URL, URL, new byte[0], new byte[0], System.currentTimeMillis())) + .andExpect(status().isOk()); + verify(offlineBundleService).build(DEVICE_ID, 7, Set.of()); + } + + /** The query string is not part of the signed path. */ + @Test + void theSignedPathExcludesTheQueryString() throws Exception { + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + mockMvc.perform(signedPost(URL + "?trace=1", URL, body, body, System.currentTimeMillis())) + .andExpect(status().isOk()); + } + + @Test + void aBodyOtherThanTheSignedOneIsA401() throws Exception { + byte[] signed = "{\"days\": 7}".getBytes(StandardCharsets.UTF_8); + byte[] sent = "{\"days\": 31}".getBytes(StandardCharsets.UTF_8); + + mockMvc.perform(signedPost(URL, URL, sent, signed, System.currentTimeMillis())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Device authentication failed: bad signature")); + verifyNoInteractions(offlineBundleService); + } + + @Test + void aStaleTimestampIsA401() throws Exception { + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + mockMvc.perform(signedPost(URL, URL, body, body, System.currentTimeMillis() - 6 * 60 * 1000L)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("server clock"))); + verifyNoInteractions(offlineBundleService); + } + + @Test + void aRevokedDeviceIsA401() throws Exception { + device.setRevokedAt(java.time.Instant.now()); + mockMvc.perform(signedPost("{}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Device authentication failed: unknown or revoked device")); + verifyNoInteractions(offlineBundleService); + } + + @Test + void noHeadersIsA401() throws Exception { + mockMvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); + verifyNoInteractions(offlineBundleService); + } + + @Test + void badHaveMediaEntriesAreA400() throws Exception { + for (String bad : new String[]{"ABC", SHA_A.toUpperCase(), SHA_A + "0", "../../etc/passwd"}) { + mockMvc.perform(signedPost("{\"haveMedia\": [\"" + bad + "\"]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("64 lowercase hex"))); + } + verifyNoInteractions(offlineBundleService); + } + + @Test + void moreThanTwoThousandHaveMediaEntriesIsA400() throws Exception { + String list = String.join(",", java.util.Collections.nCopies(2001, "\"" + SHA_A + "\"")); + mockMvc.perform(signedPost("{\"haveMedia\": [" + list + "]}")) + .andExpect(status().isBadRequest()); + + String atCap = String.join(",", java.util.Collections.nCopies(2000, "\"" + SHA_A + "\"")); + mockMvc.perform(signedPost("{\"haveMedia\": [" + atCap + "]}")) + .andExpect(status().isOk()); + } + + @Test + void malformedJsonIsA400() throws Exception { + mockMvc.perform(signedPost("{\"days\": ")) + .andExpect(status().isBadRequest()); + mockMvc.perform(signedPost("{\"days\": \"a week\"}")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(offlineBundleService); + } + + @Test + void serviceErrorsKeepTheirStatusAndJsonMessage() throws Exception { + when(offlineBundleService.build(any(), anyInt(), any())).thenThrow(new ApiResponseException( + HttpStatus.SERVICE_UNAVAILABLE, ErrorResponse.of("Content signing is not configured on this server"))); + + mockMvc.perform(signedPost("{}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.message").value("Content signing is not configured on this server")); + } + + // ==================== signing-keys and enrollment ==================== + + @Test + void signingKeysListsTheContentKeys() throws Exception { + when(signingService.publicKeys()).thenReturn(List.of( + new SigningKeyView("65b60673d6ed884b", "current=="), + new SigningKeyView("0011223344556677", "previous=="))); + + mockMvc.perform(get("/api/agent/signing-keys")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].keyId").value("65b60673d6ed884b")) + .andExpect(jsonPath("$.content[0].publicKey").value("current==")) + .andExpect(jsonPath("$.content[1].keyId").value("0011223344556677")); + } + + @Test + void signingKeysIsAnEmptyListWithoutAKey() throws Exception { + when(signingService.publicKeys()).thenReturn(List.of()); + + mockMvc.perform(get("/api/agent/signing-keys")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(0)); + } + + @Test + void enrollmentReturnsTheContentSigningKeys() throws Exception { + when(signingService.publicKeys()).thenReturn(List.of(new SigningKeyView("65b60673d6ed884b", "current=="))); + when(enrollmentService.enroll(any(), any(), any(), any(), any(), any())) + .thenReturn(new DeviceEnrollmentService.Outcome.Ok(device)); + + mockMvc.perform(post("/api/agent/enroll").contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"t\",\"publicKey\":\"k\",\"hostname\":\"h\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deviceId").value(DEVICE_ID.toString())) + .andExpect(jsonPath("$.websocketUrl").value("wss://example.test/api/agent/ws")) + .andExpect(jsonPath("$.contentSigningKeys[0].keyId").value("65b60673d6ed884b")) + .andExpect(jsonPath("$.contentSigningKeys[0].publicKey").value("current==")); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerOfflineBundleTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerOfflineBundleTest.java index ea46a72..c6bdbbc 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerOfflineBundleTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/DeviceAdminControllerOfflineBundleTest.java @@ -43,7 +43,8 @@ /** * {@code GET /api/admin/board/devices/{id}/offline-bundle}: who may download it, and that the - * HTTP shape (content type, filename, error bodies) matches the offline-mode contract. + * HTTP shape (content type, filename, error bodies) matches the signed-package contract + * (MusallahBoard {@code agent/docs/architecture.md}, sections 4.3 and 9.3). */ @WebMvcTest(controllers = DeviceAdminController.class) @Import(MethodSecurityTestConfig.class) @@ -75,8 +76,9 @@ class DeviceAdminControllerOfflineBundleTest { private UserService userService; private static OfflineBundle bundle() { - return new OfflineBundle("musallahboard-3f2a1b4c-2026-09-24.zip", List.of( - new OfflineBundle.Entry("manifest.json", "{}".getBytes(StandardCharsets.UTF_8), false))); + return new OfflineBundle("musallahboard-content-3f2a1b4c-2026-09-24.mbu", List.of( + new OfflineBundle.Entry("mbu.json", "{}".getBytes(StandardCharsets.UTF_8), false), + new OfflineBundle.Entry("mbu.sig", "{}".getBytes(StandardCharsets.UTF_8), false))); } @Test @@ -95,20 +97,21 @@ void holderOfOtherBoardPermissionsIsForbidden() throws Exception { } @Test - void deviceReadAloneIsEnoughAndStreamsTheZip() throws Exception { + void deviceReadAloneIsEnoughAndStreamsThePackage() throws Exception { when(offlineBundleService.build(DEVICE_ID, 14)).thenReturn(bundle()); MvcResult result = mockMvc.perform(get(URL).with(TestAuthorities.asHolderOf(Permission.BOARD_DEVICE_READ))) .andExpect(request().asyncNotStarted()) .andExpect(status().isOk()) - .andExpect(header().string("Content-Type", "application/zip")) + .andExpect(header().string("Content-Type", "application/vnd.musallahboard.mbu")) .andExpect(header().string("Content-Disposition", - "attachment; filename=\"musallahboard-3f2a1b4c-2026-09-24.zip\"")) + "attachment; filename=\"musallahboard-content-3f2a1b4c-2026-09-24.mbu\"")) .andReturn(); try (ZipInputStream zip = new ZipInputStream( new ByteArrayInputStream(result.getResponse().getContentAsByteArray()))) { - assertThat(zip.getNextEntry().getName()).isEqualTo("manifest.json"); + assertThat(zip.getNextEntry().getName()).isEqualTo("mbu.json"); + assertThat(zip.getNextEntry().getName()).isEqualTo("mbu.sig"); } verify(offlineBundleService).build(DEVICE_ID, 14); } @@ -135,6 +138,17 @@ void serviceErrorsKeepTheirStatusAndJsonMessage() throws Exception { "Could not fetch the image for poster \"Eid\" (poster:1): NoSuchKey")); } + @Test + void anUnconfiguredSigningKeyIsA503WithJsonMessage() throws Exception { + when(offlineBundleService.build(DEVICE_ID, 14)).thenThrow(new ApiResponseException( + HttpStatus.SERVICE_UNAVAILABLE, + ErrorResponse.of("Content signing is not configured on this server"))); + + mockMvc.perform(get(URL).with(TestAuthorities.as(Role.BOARD_VIEWER))) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.message").value("Content signing is not configured on this server")); + } + @Test void nonNumericDaysIsABadRequest() throws Exception { mockMvc.perform(get(URL).param("days", "two-weeks").with(TestAuthorities.as(Role.BOARD_VIEWER))) diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticatorTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticatorTest.java new file mode 100644 index 0000000..9bd32f6 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticatorTest.java @@ -0,0 +1,190 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; +import com.ibrasoft.lensbridge.service.agent.handshake.Ed25519Verifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Device HTTP authentication (architecture.md section 9.2) against a real Ed25519 key pair + * and a fixed clock. + */ +class DeviceRequestAuthenticatorTest { + + private static final Instant NOW = Instant.parse("2026-09-24T14:02:11.123Z"); + private static final UUID DEVICE_ID = UUID.fromString("3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b"); + private static final String PATH = "/api/agent/content-bundle"; + private static final byte[] BODY = "{\"days\":7,\"haveMedia\":[]}".getBytes(StandardCharsets.UTF_8); + + private final DeviceRepository deviceRepository = mock(DeviceRepository.class); + private final DeviceRequestAuthenticator authenticator = new DeviceRequestAuthenticator( + deviceRepository, new Ed25519Verifier(), Clock.fixed(NOW, ZoneOffset.UTC)); + + private KeyPair keys; + private Device device; + + @BeforeEach + void setUp() { + keys = Ed25519TestUtil.generate(); + device = Device.builder() + .id(DEVICE_ID) + .displayName("Lobby board") + .audience(Audience.BROTHERS) + .publicKey(Ed25519TestUtil.rawPublicKey(keys.getPublic())) + .build(); + when(deviceRepository.findById(DEVICE_ID)).thenReturn(Optional.of(device)); + } + + /** A request exactly as a well-behaved agent sends it. */ + private MockHttpServletRequest signed(String method, String path, long timestamp, byte[] signedBody) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.addHeader(DeviceRequestAuthenticator.DEVICE_ID_HEADER, DEVICE_ID.toString()); + request.addHeader(DeviceRequestAuthenticator.TIMESTAMP_HEADER, Long.toString(timestamp)); + byte[] message = DeviceRequestAuthenticator.message(method, path, DEVICE_ID, timestamp, signedBody); + request.addHeader(DeviceRequestAuthenticator.SIGNATURE_HEADER, + Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(keys.getPrivate(), message))); + return request; + } + + private void assertRejected(MockHttpServletRequest request, byte[] body, String reason) { + assertThatThrownBy(() -> authenticator.authenticate(request, body)) + .isInstanceOfSatisfying(ApiResponseException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(((MessageResponse) e.getBody()).getMessage()) + .startsWith("Device authentication failed: ") + .contains(reason); + }); + } + + @Test + void theSignedMessageIsExactlyTheDocumentedLayout() { + byte[] message = DeviceRequestAuthenticator.message("POST", PATH, DEVICE_ID, 1790258531000L, new byte[0]); + + assertThat(new String(message, StandardCharsets.UTF_8)).isEqualTo( + "musallahboard-http-v1\n" + + "POST\n" + + "/api/agent/content-bundle\n" + + "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b\n" + + "1790258531000\n" + // SHA-256 of the empty string + + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + } + + @Test + void aCorrectlySignedRequestReturnsTheDevice() { + assertThat(authenticator.authenticate(signed("POST", PATH, NOW.toEpochMilli(), BODY), BODY)) + .isSameAs(device); + } + + @Test + void anEmptyBodyIsHashedAsTheEmptyString() { + assertThat(authenticator.authenticate(signed("GET", PATH, NOW.toEpochMilli(), new byte[0]), null)) + .isSameAs(device); + } + + @Test + void timestampsUpToFiveMinutesEitherWayAreAccepted() { + long fiveMinutes = 5 * 60 * 1000L; + for (long ts : new long[]{NOW.toEpochMilli() - fiveMinutes, NOW.toEpochMilli() + fiveMinutes}) { + assertThat(authenticator.authenticate(signed("POST", PATH, ts, BODY), BODY)).isSameAs(device); + } + } + + @Test + void timestampsFurtherOffAreRejected() { + long justOver = 5 * 60 * 1000L + 1; + assertRejected(signed("POST", PATH, NOW.toEpochMilli() - justOver, BODY), BODY, "server clock"); + assertRejected(signed("POST", PATH, NOW.toEpochMilli() + justOver, BODY), BODY, "server clock"); + } + + @Test + void aTamperedBodyIsRejected() { + byte[] tampered = "{\"days\":31,\"haveMedia\":[]}".getBytes(StandardCharsets.UTF_8); + assertRejected(signed("POST", PATH, NOW.toEpochMilli(), BODY), tampered, "bad signature"); + } + + @Test + void aSignatureForAnotherPathOrMethodIsRejected() { + MockHttpServletRequest otherPath = signed("POST", "/api/agent/other", NOW.toEpochMilli(), BODY); + otherPath.setRequestURI(PATH); + assertRejected(otherPath, BODY, "bad signature"); + + MockHttpServletRequest otherMethod = signed("GET", PATH, NOW.toEpochMilli(), BODY); + otherMethod.setMethod("POST"); + assertRejected(otherMethod, BODY, "bad signature"); + } + + @Test + void aSignatureByAnotherKeyIsRejected() { + device.setPublicKey(Ed25519TestUtil.rawPublicKey(Ed25519TestUtil.generate().getPublic())); + assertRejected(signed("POST", PATH, NOW.toEpochMilli(), BODY), BODY, "bad signature"); + } + + @Test + void revokedUnknownAndKeylessDevicesAreRejectedAlike() { + device.setRevokedAt(NOW.minusSeconds(60)); + assertRejected(signed("POST", PATH, NOW.toEpochMilli(), BODY), BODY, "unknown or revoked device"); + + device.setRevokedAt(null); + device.setPublicKey(null); + assertRejected(signed("POST", PATH, NOW.toEpochMilli(), BODY), BODY, "unknown or revoked device"); + + when(deviceRepository.findById(DEVICE_ID)).thenReturn(Optional.empty()); + assertRejected(signed("POST", PATH, NOW.toEpochMilli(), BODY), BODY, "unknown or revoked device"); + } + + @Test + void missingOrMalformedHeadersAreRejectedBeforeTheDatabase() { + DeviceRepository untouched = mock(DeviceRepository.class); + DeviceRequestAuthenticator strict = new DeviceRequestAuthenticator( + untouched, new Ed25519Verifier(), Clock.fixed(NOW, ZoneOffset.UTC)); + + MockHttpServletRequest none = new MockHttpServletRequest("POST", PATH); + assertThatThrownBy(() -> strict.authenticate(none, BODY)).isInstanceOf(ApiResponseException.class); + + MockHttpServletRequest badId = signed("POST", PATH, NOW.toEpochMilli(), BODY); + badId.removeHeader(DeviceRequestAuthenticator.DEVICE_ID_HEADER); + badId.addHeader(DeviceRequestAuthenticator.DEVICE_ID_HEADER, "board-1"); + assertThatThrownBy(() -> strict.authenticate(badId, BODY)) + .isInstanceOfSatisfying(ApiResponseException.class, + e -> assertThat(((MessageResponse) e.getBody()).getMessage()).contains("not a UUID")); + + MockHttpServletRequest badTs = signed("POST", PATH, NOW.toEpochMilli(), BODY); + badTs.removeHeader(DeviceRequestAuthenticator.TIMESTAMP_HEADER); + badTs.addHeader(DeviceRequestAuthenticator.TIMESTAMP_HEADER, "2026-09-24T14:02:11Z"); + assertThatThrownBy(() -> strict.authenticate(badTs, BODY)) + .isInstanceOfSatisfying(ApiResponseException.class, + e -> assertThat(((MessageResponse) e.getBody()).getMessage()).contains("Unix milliseconds")); + + MockHttpServletRequest badSig = signed("POST", PATH, NOW.toEpochMilli(), BODY); + badSig.removeHeader(DeviceRequestAuthenticator.SIGNATURE_HEADER); + badSig.addHeader(DeviceRequestAuthenticator.SIGNATURE_HEADER, "***"); + assertThatThrownBy(() -> strict.authenticate(badSig, BODY)) + .isInstanceOfSatisfying(ApiResponseException.class, + e -> assertThat(((MessageResponse) e.getBody()).getMessage()).contains("not base64")); + + verifyNoInteractions(untouched); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningServiceTest.java new file mode 100644 index 0000000..dab8b50 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningServiceTest.java @@ -0,0 +1,174 @@ +package com.ibrasoft.lensbridge.service.board.offline; + +import com.ibrasoft.lensbridge.dto.board.response.SigningKeyView; +import com.ibrasoft.lensbridge.exception.ApiResponseException; +import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PublicKey; +import java.security.Signature; +import java.security.interfaces.EdECPrivateKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Key loading, key ids and package signatures, checked against the JDK's own verifier and + * against a fixed cross-language test vector shared with the Go agent and the Node packer. + */ +class ContentSigningServiceTest { + + /** + * Cross-language test vector. The Go agent ({@code internal/trust}) and the Node packer + * must derive the same values from the same seed: + *

+     * seed (hex)      0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20
+     * public key (b64) ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=
+     * keyId           65b60673d6ed884b
+     * 
+ * Signing the manifest {@code {"format":"mbu"}} (see {@link #crossLanguageTestVector()}) + * signs these 37 bytes, hex: + *
+     * 6d7573616c6c6168626f6172642d6d62752d76320a7b22666f726d6174223a226d6275227d
+     * 
+ * and, Ed25519 being deterministic, always yields this signature (base64): + *
+     * PDYOh2jMeZK5PgkxhjAIOXJzTLyWVsGfilFMSDukwg0iTN9xmy3KgQjJwjTM1NnucaX01mVcFoPxDhBebodbAw==
+     * 
+ */ + static final byte[] VECTOR_SEED = HexFormat.of().parseHex( + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + static final String VECTOR_KEY_ID = "65b60673d6ed884b"; + + private static final String VECTOR_SEED_B64 = Base64.getEncoder().encodeToString(VECTOR_SEED); + + /** Verifies with the JDK provider directly, independent of any code under test. */ + static boolean jdkVerify(byte[] rawPublicKey, byte[] message, byte[] signature) throws Exception { + byte[] x509 = new byte[44]; + System.arraycopy(HexFormat.of().parseHex("302a300506032b6570032100"), 0, x509, 0, 12); + System.arraycopy(rawPublicKey, 0, x509, 12, 32); + PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(x509)); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(key); + verifier.update(message); + return verifier.verify(signature); + } + + @Test + void crossLanguageTestVector() throws Exception { + ContentSigningService service = new ContentSigningService(VECTOR_SEED_B64, ""); + SigningKeyView key = service.publicKeys().get(0); + byte[] manifest = "{\"format\":\"mbu\"}".getBytes(StandardCharsets.UTF_8); + byte[] message = ContentSigningService.mbuSignedMessage(manifest); + byte[] signature = service.signManifest(manifest); + + // Printed so the values can be copied into the other implementations' tests. + System.out.println("content key test vector: seed=" + HexFormat.of().formatHex(VECTOR_SEED) + + " publicKey=" + key.getPublicKey() + + " keyId=" + key.getKeyId() + + " signedMessageHex=" + HexFormat.of().formatHex(message) + + " signature=" + Base64.getEncoder().encodeToString(signature)); + + assertThat(key.getKeyId()).isEqualTo(VECTOR_KEY_ID); + assertThat(service.currentKeyId()).isEqualTo(VECTOR_KEY_ID); + assertThat(key.getPublicKey()).isEqualTo("ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ="); + assertThat(HexFormat.of().formatHex(message)) + .isEqualTo("6d7573616c6c6168626f6172642d6d62752d76320a7b22666f726d6174223a226d6275227d"); + assertThat(Base64.getEncoder().encodeToString(signature)).isEqualTo("PDYOh2jMeZK5PgkxhjAIOXJzTLyWVsGfilFMSDukwg0iTN9xmy3KgQjJwjTM1NnucaX01mVcFoPxDhBebodbAw=="); + assertThat(jdkVerify(Base64.getDecoder().decode(key.getPublicKey()), message, signature)).isTrue(); + } + + /** The derived public key must be the one the JDK generated for that seed, and the key id its hash prefix. */ + @Test + void publicKeyAndKeyIdAreDerivedFromTheSeed() throws Exception { + KeyPair pair = Ed25519TestUtil.generate(); + byte[] seed = ((EdECPrivateKey) pair.getPrivate()).getBytes().orElseThrow(); + byte[] expectedPublic = Ed25519TestUtil.rawPublicKey(pair.getPublic()); + + ContentSigningService service = new ContentSigningService(Base64.getEncoder().encodeToString(seed), ""); + + SigningKeyView key = service.publicKeys().get(0); + assertThat(Base64.getDecoder().decode(key.getPublicKey())).isEqualTo(expectedPublic); + byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(expectedPublic); + assertThat(key.getKeyId()) + .isEqualTo(HexFormat.of().formatHex(digest, 0, 8)) + .matches("^[0-9a-f]{16}$"); + } + + @Test + void signatureCoversThePrefixFollowedByTheExactManifestBytes() throws Exception { + ContentSigningService service = new ContentSigningService(VECTOR_SEED_B64, ""); + byte[] manifest = "{\"format\":\"mbu\",\"formatVersion\":2}".getBytes(StandardCharsets.UTF_8); + byte[] publicKey = Base64.getDecoder().decode(service.publicKeys().get(0).getPublicKey()); + + byte[] signature = service.signManifest(manifest); + + byte[] prefixed = ("musallahboard-mbu-v2\n" + new String(manifest, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8); + assertThat(jdkVerify(publicKey, prefixed, signature)).isTrue(); + // Not the bare manifest, and not another domain's prefix. + assertThat(jdkVerify(publicKey, manifest, signature)).isFalse(); + byte[] otherDomain = ("musallahboard-auth-v1\n" + new String(manifest, StandardCharsets.UTF_8)) + .getBytes(StandardCharsets.UTF_8); + assertThat(jdkVerify(publicKey, otherDomain, signature)).isFalse(); + } + + @Test + void previousKeysArePublishedAfterTheCurrentOneWithoutDuplicates() { + byte[] previous = Ed25519TestUtil.rawPublicKey(Ed25519TestUtil.generate().getPublic()); + String previousB64 = Base64.getEncoder().encodeToString(previous); + String currentB64 = new ContentSigningService(VECTOR_SEED_B64, "").publicKeys().get(0).getPublicKey(); + + ContentSigningService service = new ContentSigningService(VECTOR_SEED_B64, + " " + previousB64 + " ,," + currentB64 + "," + previousB64); + + assertThat(service.publicKeys()).extracting(SigningKeyView::getPublicKey) + .containsExactly(currentB64, previousB64); + assertThat(service.publicKeys().get(1).getKeyId()).isEqualTo(ContentSigningService.keyId(previous)); + } + + @Test + void withoutAKeyTheServiceStartsPublishesNothingAndSigningIsA503() { + for (String unset : new String[]{null, "", " "}) { + ContentSigningService service = new ContentSigningService(unset, ""); + + assertThat(service.isConfigured()).isFalse(); + assertThat(service.publicKeys()).isEmpty(); + assertThat(service.currentKeyId()).isNull(); + assertThatThrownBy(service::requireConfigured) + .isInstanceOfSatisfying(ApiResponseException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)); + assertThatThrownBy(() -> service.signManifest(new byte[]{1})) + .isInstanceOfSatisfying(ApiResponseException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)); + } + } + + /** Without a current key there is nothing to rotate from, so previous keys are not published either. */ + @Test + void previousKeysAloneAreNotPublished() { + String previous = Base64.getEncoder().encodeToString( + Ed25519TestUtil.rawPublicKey(Ed25519TestUtil.generate().getPublic())); + + assertThat(new ContentSigningService("", previous).publicKeys()).isEmpty(); + } + + @Test + void aMalformedKeyFailsStartupRatherThanSilentlyDisablingSigning() { + assertThatThrownBy(() -> new ContentSigningService("not base64!", "")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("not valid base64"); + assertThatThrownBy(() -> new ContentSigningService(Base64.getEncoder().encodeToString(new byte[31]), "")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("32-byte"); + assertThatThrownBy(() -> new ContentSigningService(VECTOR_SEED_B64, "AAAA")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("previous-public-keys"); + assertThatThrownBy(() -> new ContentSigningService(VECTOR_SEED_B64, "%%%")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("previous-public-keys"); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCacheTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCacheTest.java new file mode 100644 index 0000000..3dd5698 --- /dev/null +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/MediaMetadataCacheTest.java @@ -0,0 +1,56 @@ +package com.ibrasoft.lensbridge.service.board.offline; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; + +class MediaMetadataCacheTest { + + /** A clock the test can move. */ + private static final class MutableClock extends Clock { + Instant now = Instant.parse("2026-09-24T12:00:00Z"); + + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } + + private static MediaMetadataCache.MediaMetadata meta(String sha) { + return new MediaMetadataCache.MediaMetadata("media/" + sha + ".jpg", sha, 10, "image/jpeg"); + } + + @Test + void entriesExpireAfterTheTtl() { + MutableClock clock = new MutableClock(); + MediaMetadataCache cache = new MediaMetadataCache(clock); + cache.put("a.jpg", meta("aa")); + + clock.now = clock.now.plus(Duration.ofMinutes(60)); + assertThat(cache.get("a.jpg")).isEqualTo(meta("aa")); + + clock.now = clock.now.plusSeconds(1); + assertThat(cache.get("a.jpg")).isNull(); + assertThat(cache.size()).isZero(); + } + + @Test + void theLeastRecentlyUsedEntryIsEvictedAtCapacity() { + MediaMetadataCache cache = new MediaMetadataCache(2, Duration.ofHours(1), new MutableClock()); + cache.put("a.jpg", meta("aa")); + cache.put("b.jpg", meta("bb")); + cache.get("a.jpg"); // a is now more recent than b + + cache.put("c.jpg", meta("cc")); + + assertThat(cache.size()).isEqualTo(2); + assertThat(cache.get("b.jpg")).isNull(); + assertThat(cache.get("a.jpg")).isNotNull(); + assertThat(cache.get("c.jpg")).isNotNull(); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java index 7e3e09f..6c9c5d5 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java @@ -36,11 +36,13 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Base64; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -57,8 +59,8 @@ import static org.mockito.Mockito.when; /** - * Runs the real assembler and the real {@link PosterFrameProducer}; only storage, the - * repositories and the clock are faked. + * Runs the real assembler, the real {@link PosterFrameProducer} and the real signer (with a + * fixed test key); only storage, the repositories and the clock are faked. *

* The clock is 23:30 on Friday 2026-10-30 in Toronto — already the 31st in UTC — and a * three-day bundle ends on Sunday 2026-11-01, the day Toronto leaves DST (25 hours long). @@ -81,6 +83,11 @@ class OfflineBundleServiceTest { .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); + /** Seed 0x01..0x20: the cross-language test vector, see ContentSigningServiceTest. */ + private static final byte[] SEED = ContentSigningServiceTest.VECTOR_SEED; + private final ContentSigningService signer = + new ContentSigningService(Base64.getEncoder().encodeToString(SEED), ""); + private final List posters = new ArrayList<>(); private final List seenContexts = new ArrayList<>(); private Device device; @@ -123,7 +130,7 @@ private OfflineBundleService service() { BoardPayloadAssembler assembler = new BoardPayloadAssembler( deviceRepository, weatherService, List.of(recorder, new PosterFrameProducer(posterService)), ZoneId.of("UTC")); - return new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, + return new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, signer, Clock.fixed(NOW, ZoneOffset.UTC)); } @@ -158,6 +165,26 @@ private static Map unzip(OfflineBundle bundle) throws IOExceptio return entries; } + private static Map zipMethods(OfflineBundle bundle) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bundle.writeTo(out); + Map methods = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(out.toByteArray()))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + methods.put(entry.getName(), entry.getMethod()); + } + } + return methods; + } + + private static JsonNode fileEntry(JsonNode manifest, String path) { + for (JsonNode file : manifest.get("files")) { + if (file.get("path").asText().equals(path)) return file; + } + throw new AssertionError("mbu.json files does not list " + path); + } + private JsonNode json(Map zip, String name) throws IOException { assertThat(zip).containsKey(name); return objectMapper.readTree(zip.get(name)); @@ -189,13 +216,14 @@ private static String sha256(byte[] bytes) throws Exception { // ==================== Layout and manifest ==================== @Test - void zipHoldsTheManifestOnePayloadPerDayAndTheMediaAndNothingElse() throws Exception { + void zipHoldsTheManifestSignatureOnePayloadPerDayAndTheMediaAndNothingElse() throws Exception { poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); Map zip = unzip(service().build(DEVICE_ID, 3)); assertThat(zip.keySet()).containsExactlyInAnyOrder( - "manifest.json", + "mbu.json", + "mbu.sig", "payloads/2026-10-30.json", "payloads/2026-10-31.json", "payloads/2026-11-01.json", @@ -206,30 +234,101 @@ void zipHoldsTheManifestOnePayloadPerDayAndTheMediaAndNothingElse() throws Excep void manifestCarriesTheContractFields() throws Exception { poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); - JsonNode manifest = json(unzip(service().build(DEVICE_ID, 3)), "manifest.json"); + JsonNode manifest = json(unzip(service().build(DEVICE_ID, 3)), "mbu.json"); + assertThat(manifest.fieldNames()).toIterable().containsExactly( + "format", "formatVersion", "type", "createdAt", "sequence", "deviceId", "version", "files", "content"); + assertThat(manifest.get("format").asText()).isEqualTo("mbu"); assertThat(manifest.get("formatVersion").isInt()).isTrue(); - assertThat(manifest.get("formatVersion").asInt()).isEqualTo(1); + assertThat(manifest.get("formatVersion").asInt()).isEqualTo(2); + assertThat(manifest.get("type").asText()).isEqualTo("content"); + assertThat(manifest.get("createdAt").asText()).isEqualTo("2026-10-31T03:30:00Z"); + assertThat(manifest.get("sequence").isIntegralNumber()).isTrue(); + assertThat(manifest.get("sequence").asLong()).isEqualTo(NOW.toEpochMilli()); assertThat(manifest.get("deviceId").asText()).isEqualTo(DEVICE_ID.toString()); - assertThat(manifest.get("timezone").asText()).isEqualTo("America/Toronto"); - assertThat(manifest.get("generatedAt").asText()).isEqualTo("2026-10-31T03:30:00Z"); + assertThat(manifest.get("version").isNull()).isTrue(); + + JsonNode content = manifest.get("content"); + assertThat(content.get("timezone").asText()).isEqualTo("America/Toronto"); // Today in Toronto, not in UTC (where it is already the 31st). - assertThat(manifest.get("firstDay").asText()).isEqualTo("2026-10-30"); - assertThat(manifest.get("lastDay").asText()).isEqualTo("2026-11-01"); + assertThat(content.get("firstDay").asText()).isEqualTo("2026-10-30"); + assertThat(content.get("lastDay").asText()).isEqualTo("2026-11-01"); String sha = sha256(JPEG); - assertThat(manifest.get("media")).hasSize(1); - JsonNode media = manifest.get("media").get(0); + assertThat(content.get("media")).hasSize(1); + JsonNode media = content.get("media").get(0); assertThat(media.get("path").asText()).isEqualTo("media/" + sha + ".jpg"); - assertThat(media.get("sha256").asText()).isEqualTo(sha); - assertThat(media.get("bytes").asLong()).isEqualTo(JPEG.length); assertThat(media.get("contentType").asText()).isEqualTo("image/jpeg"); + + JsonNode mediaFile = fileEntry(manifest, "media/" + sha + ".jpg"); + assertThat(mediaFile.get("sha256").asText()).isEqualTo(sha); + assertThat(mediaFile.get("bytes").asLong()).isEqualTo(JPEG.length); + } + + /** Every zip entry but mbu.json and mbu.sig is listed with its true hash and size, and vice versa. */ + @Test + void filesListsEveryPayloadAndMediaFileWithItsHashAndSize() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + poster("Iftar", "poster-b.png", "2026-10-01T00:00", "2026-12-01T00:00"); + + Map zip = unzip(service().build(DEVICE_ID, 3)); + JsonNode files = json(zip, "mbu.json").get("files"); + + List listed = new ArrayList<>(); + for (JsonNode file : files) { + String path = file.get("path").asText(); + listed.add(path); + assertThat(file.get("sha256").asText()).isEqualTo(sha256(zip.get(path))); + assertThat(file.get("bytes").asLong()).isEqualTo(zip.get(path).length); + } + assertThat(listed).containsExactlyInAnyOrderElementsOf(zip.keySet().stream() + .filter(n -> !n.equals("mbu.json") && !n.equals("mbu.sig")).toList()); + } + + @Test + void signatureVerifiesOverThePrefixAndTheStoredManifestBytes() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + + Map zip = unzip(service().build(DEVICE_ID, 3)); + JsonNode sig = json(zip, "mbu.sig"); + + assertThat(sig.fieldNames()).toIterable().containsExactly("signatures"); + assertThat(sig.get("signatures")).hasSize(1); + JsonNode entry = sig.get("signatures").get(0); + assertThat(entry.get("keyId").asText()).isEqualTo(ContentSigningServiceTest.VECTOR_KEY_ID); + + byte[] prefix = "musallahboard-mbu-v2\n".getBytes(StandardCharsets.US_ASCII); + byte[] manifest = zip.get("mbu.json"); + byte[] message = new byte[prefix.length + manifest.length]; + System.arraycopy(prefix, 0, message, 0, prefix.length); + System.arraycopy(manifest, 0, message, prefix.length, manifest.length); + byte[] signature = Base64.getDecoder().decode(entry.get("sig").asText()); + assertThat(signature).hasSize(64); + + byte[] publicKey = Base64.getDecoder().decode(signer.publicKeys().get(0).getPublicKey()); + assertThat(ContentSigningServiceTest.jdkVerify(publicKey, message, signature)).isTrue(); + + // One byte changed anywhere in the manifest and it no longer verifies. + message[message.length - 2] ^= 1; + assertThat(ContentSigningServiceTest.jdkVerify(publicKey, message, signature)).isFalse(); + } + + @Test + void mediaIsStoredAndJsonIsDeflated() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + + Map methods = zipMethods(service().build(DEVICE_ID, 2)); + + assertThat(methods.get("media/" + sha256(JPEG) + ".jpg")).isEqualTo(ZipEntry.STORED); + assertThat(methods.get("mbu.json")).isEqualTo(ZipEntry.DEFLATED); + assertThat(methods.get("mbu.sig")).isEqualTo(ZipEntry.DEFLATED); + assertThat(methods.get("payloads/2026-10-30.json")).isEqualTo(ZipEntry.DEFLATED); } @Test - void filenameUsesTheFirstEightCharactersOfTheIdAndTheFirstDay() { + void filenameFollowsTheContentPackageConvention() { assertThat(service().build(DEVICE_ID, 14).filename()) - .isEqualTo("musallahboard-3f2a1b4c-2026-10-30.zip"); + .isEqualTo("musallahboard-content-3f2a1b4c-2026-10-30.mbu"); } @Test @@ -237,7 +336,89 @@ void defaultWindowOfFourteenDaysHasFourteenPayloads() throws Exception { Map zip = unzip(service().build(DEVICE_ID, 14)); assertThat(zip.keySet().stream().filter(n -> n.startsWith("payloads/"))).hasSize(14); - assertThat(json(zip, "manifest.json").get("lastDay").asText()).isEqualTo("2026-11-12"); + assertThat(json(zip, "mbu.json").get("content").get("lastDay").asText()).isEqualTo("2026-11-12"); + } + + // ==================== Signing key ==================== + + @Test + void withoutASigningKeyTheBuildIsA503AndNothingIsAssembledOrFetched() { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + BoardPayloadAssembler assembler = new BoardPayloadAssembler( + deviceRepository, weatherService, List.of(new PosterFrameProducer(posterService)), ZoneId.of("UTC")); + OfflineBundleService unsigned = new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, + new ContentSigningService("", ""), Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy(() -> unsigned.build(DEVICE_ID, 3)) + .isInstanceOfSatisfying(ApiResponseException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(((ErrorResponse) e.getBody()).getError()).contains("signing is not configured"); + }); + verifyNoInteractions(posterService); + verifyNoInteractions(r2); + } + + // ==================== haveMedia (online delta sync) ==================== + + @Test + void mediaTheBoardHoldsIsListedAndSignedButLeftOutOfTheZip() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + poster("Iftar", "poster-b.png", "2026-10-01T00:00", "2026-12-01T00:00"); + String jpeg = "media/" + sha256(JPEG) + ".jpg"; + String png = "media/" + sha256(PNG) + ".png"; + + Map zip = unzip(service().build(DEVICE_ID, 2, Set.of(sha256(JPEG)))); + + assertThat(zip).doesNotContainKey(jpeg).containsKey(png); + JsonNode manifest = json(zip, "mbu.json"); + assertThat(fileEntry(manifest, jpeg).get("sha256").asText()).isEqualTo(sha256(JPEG)); + assertThat(fileEntry(manifest, jpeg).get("bytes").asLong()).isEqualTo(JPEG.length); + assertThat(manifest.get("content").get("media")).hasSize(2); + assertThat(posterUrls(json(zip, "payloads/2026-10-30.json"))) + .containsExactlyInAnyOrder("/" + jpeg, "/" + png); + } + + /** + * The first build learns each image's hash by downloading it. A later sync from a board + * that holds the image must not download it again. + */ + @Test + void aSyncWhereTheBoardHoldsEveryImageDownloadsNothingOnceHashesAreKnown() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + OfflineBundleService service = service(); + + Map first = unzip(service.build(DEVICE_ID, 7)); + verify(r2, times(1)).getObject("poster-a.jpg"); + assertThat(first).containsKey("media/" + sha256(JPEG) + ".jpg"); + + Map second = unzip(service.build(DEVICE_ID, 7, Set.of(sha256(JPEG)))); + + verify(r2, times(1)).getObject("poster-a.jpg"); // still just the first download + assertThat(second.keySet().stream().filter(n -> n.startsWith("media/"))).isEmpty(); + assertThat(fileEntry(json(second, "mbu.json"), "media/" + sha256(JPEG) + ".jpg")).isNotNull(); + } + + @Test + void anImageTheBoardLacksIsDownloadedEvenWhenItsHashIsCached() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + OfflineBundleService service = service(); + service.build(DEVICE_ID, 1); + + Map zip = unzip(service.build(DEVICE_ID, 1, Set.of(sha256(PNG)))); + + verify(r2, times(2)).getObject("poster-a.jpg"); + assertThat(zip.get("media/" + sha256(JPEG) + ".jpg")).isEqualTo(JPEG); + } + + /** Hash not cached yet: the image is downloaded to learn it, but still left out of the zip. */ + @Test + void anUncachedImageTheBoardHoldsIsHashedButNotShipped() throws Exception { + poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); + + Map zip = unzip(service().build(DEVICE_ID, 1, Set.of(sha256(JPEG)))); + + verify(r2, times(1)).getObject("poster-a.jpg"); + assertThat(zip.keySet().stream().filter(n -> n.startsWith("media/"))).isEmpty(); } // ==================== Per-day assembly ==================== @@ -341,7 +522,7 @@ void eachImageIsFetchedOnceAndIdenticalBytesAreStoredOnce() throws Exception { verify(r2, times(1)).getObject("poster-a.jpg"); verify(r2, times(1)).getObject("poster-copy.jpg"); assertThat(zip.keySet().stream().filter(n -> n.startsWith("media/"))).hasSize(1); - assertThat(json(zip, "manifest.json").get("media")).hasSize(1); + assertThat(json(zip, "mbu.json").get("content").get("media")).hasSize(1); assertThat(posterUrls(json(zip, "payloads/2026-11-05.json"))) .containsOnly("/media/" + sha256(JPEG) + ".jpg"); } @@ -351,9 +532,9 @@ void extensionAndContentTypeFallBackToTheObjectKey() throws Exception { poster("Untyped", "poster-u.PNG", "2026-10-01T00:00", "2026-12-01T00:00"); when(r2.getObject("poster-u.PNG")).thenReturn(new R2StorageService.R2Object("poster-u.PNG", PNG, null)); - JsonNode manifest = json(unzip(service().build(DEVICE_ID, 1)), "manifest.json"); + JsonNode manifest = json(unzip(service().build(DEVICE_ID, 1)), "mbu.json"); - JsonNode media = manifest.get("media").get(0); + JsonNode media = manifest.get("content").get("media").get(0); assertThat(media.get("path").asText()).isEqualTo("media/" + sha256(PNG) + ".png"); assertThat(media.get("contentType").asText()).isEqualTo("image/png"); } @@ -364,12 +545,26 @@ void aGenericStoredContentTypeDefersToTheObjectKey() throws Exception { when(r2.getObject("poster-o.jpeg")).thenReturn( new R2StorageService.R2Object("poster-o.jpeg", JPEG, "application/octet-stream")); - JsonNode media = json(unzip(service().build(DEVICE_ID, 1)), "manifest.json").get("media").get(0); + JsonNode media = json(unzip(service().build(DEVICE_ID, 1)), "mbu.json").get("content").get("media").get(0); assertThat(media.get("path").asText()).isEqualTo("media/" + sha256(JPEG) + ".jpeg"); assertThat(media.get("contentType").asText()).isEqualTo("image/jpeg"); } + /** The agent accepts only a fixed list of media types; anything else must not reject the package. */ + @Test + void aStoredTypeTheAgentDoesNotAcceptBecomesOctetStream() throws Exception { + byte[] heic = "heic-bytes".getBytes(StandardCharsets.UTF_8); + poster("Phone photo", "poster-h.heic", "2026-10-01T00:00", "2026-12-01T00:00"); + when(r2.getObject("poster-h.heic")).thenReturn( + new R2StorageService.R2Object("poster-h.heic", heic, "image/heic")); + + JsonNode media = json(unzip(service().build(DEVICE_ID, 1)), "mbu.json").get("content").get("media").get(0); + + assertThat(media.get("path").asText()).isEqualTo("media/" + sha256(heic) + ".heic"); + assertThat(media.get("contentType").asText()).isEqualTo("application/octet-stream"); + } + @Test void anUnfetchableImageFailsTheWholeExportWith502NamingThePoster() throws Exception { poster("Fine", "fine.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); @@ -428,10 +623,10 @@ void revokedDeviceIsA409() { void deviceWithoutATimezoneUsesTheDefaultZone() throws Exception { device.setConfig(null); - JsonNode manifest = json(unzip(service().build(DEVICE_ID, 1)), "manifest.json"); + JsonNode content = json(unzip(service().build(DEVICE_ID, 1)), "mbu.json").get("content"); // The assembler's default zone here is UTC, where NOW is already the 31st. - assertThat(manifest.get("timezone").asText()).isEqualTo("UTC"); - assertThat(manifest.get("firstDay").asText()).isEqualTo("2026-10-31"); + assertThat(content.get("timezone").asText()).isEqualTo("UTC"); + assertThat(content.get("firstDay").asText()).isEqualTo("2026-10-31"); } } From 27c73b57fbca027f520960597b1818f3049b97f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:51:04 +0000 Subject: [PATCH 2/6] Retire the live board payload endpoint and add agent weather Boards are local-first: they install signed per-day content packages and never fetch the live payload, so GET /api/musallah/payload goes, along with what only it used: - BoardPayloadAssembler.assemble(UUID), its lenient per-producer path and its DeviceRepository/OpenWeatherService dependencies - BoardContext.of(Device, ZoneId) and the wholeDay flag; every context is now a whole day - the "active right now" poster path: PosterFrameProducer's live branch, PosterService.getActivePosterFramesForAudience and PosterRepository.findActivePostersForAudienceAt Weather cannot live in a package built days ahead, so boards fetch it from the new GET /api/agent/weather, device-authenticated like the content bundle (signature over the empty body). It returns {"weather": or null, "fetchedAt"} and never 5xx because weather is unavailable. openapi.yaml drops the payload path and adds the weather path. The MusallahBoardPayload schema stays as a component (registered in OpenApiConfig) since it is still the payloads/.json format. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- openapi.yaml | 94 +++++++++--- .../lensbridge/config/OpenApiConfig.java | 24 +++ .../controller/AgentContentController.java | 60 +++++++- .../controller/MusallahBoardController.java | 10 -- .../board/response/AgentWeatherResponse.java | 33 ++++ .../board/response/MusallahBoardPayload.java | 9 ++ .../repository/sql/PosterRepository.java | 6 +- .../security/WebSecurityConfig.java | 1 + .../lensbridge/service/PosterService.java | 9 -- .../service/board/BoardContext.java | 33 ++-- .../service/board/BoardPayloadAssembler.java | 63 ++------ .../board/offline/OfflineBundleService.java | 6 +- .../board/producer/PosterFrameProducer.java | 11 +- .../AgentContentControllerTest.java | 92 ++++++++++- .../lensbridge/service/PosterServiceTest.java | 9 -- .../service/board/BoardContextTest.java | 23 +-- .../board/BoardPayloadAssemblerTest.java | 144 +++++------------- .../offline/OfflineBundleServiceTest.java | 17 +-- .../producer/PosterFrameProducerTest.java | 22 +-- 19 files changed, 359 insertions(+), 307 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentWeatherResponse.java diff --git a/openapi.yaml b/openapi.yaml index 98b87e3..3ed5675 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2259,6 +2259,58 @@ paths: summary: Public content signing keys boards should trust tags: - agent-content-controller + /api/agent/weather: + get: + description: "Device-authenticated with the X-MB-* headers, like the content\ + \ bundle; the signed body hash is the SHA-256 of the empty string. `weather`\ + \ is the OpenWeatherMap current weather JSON exactly as the server last fetched\ + \ it, or null when the server has none. `fetchedAt` is when the server answered." + operationId: getAgentWeather + parameters: + - description: Enrolled device id + in: header + name: X-MB-Device-Id + required: true + schema: + type: string + format: uuid + - description: Unix milliseconds; must be within 5 minutes of the server clock + in: header + name: X-MB-Timestamp + required: true + schema: + type: integer + format: int64 + - description: Base64 Ed25519 signature by the device key + in: header + name: X-MB-Signature + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AgentWeatherResponse" + description: "Current weather, or null weather when unavailable" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: "Missing or bad device signature, clock skew over 5 minutes,\ + \ or an unknown or revoked device" + default: + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + description: Request failed; body carries a human-readable message + security: [] + summary: Current weather for this board + tags: + - agent-content-controller /api/auth/change-password: post: description: "Revokes every refresh token for the user, so all sessions must\ @@ -2845,31 +2897,6 @@ paths: description: Request failed; body carries a human-readable message tags: - musallah-board-controller - /api/musallah/payload: - get: - operationId: getBoardPayload - parameters: - - in: query - name: deviceId - required: true - schema: - type: string - format: uuid - responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/MusallahBoardPayload" - description: OK - default: - content: - application/json: - schema: - $ref: "#/components/schemas/MessageResponse" - description: Request failed; body carries a human-readable message - tags: - - musallah-board-controller /api/musallah/weekly-content: get: operationId: getCurrentWeeklyContent @@ -3356,6 +3383,19 @@ components: format: uuid websocketUrl: type: string + AgentWeatherResponse: + type: object + properties: + fetchedAt: + type: string + format: date-time + example: 2026-09-24T14:05:00Z + weather: + $ref: "#/components/schemas/JsonNode" + description: "OpenWeatherMap current weather JSON, verbatim; null when unavailable" + required: + - fetchedAt + - weather AuditEventDto: type: object properties: @@ -4074,6 +4114,10 @@ components: type: string MusallahBoardPayload: type: object + description: "Format of each payloads/.json file inside a signed content\ + \ package (.mbu). Not returned by any endpoint; boards read it from installed\ + \ packages. weather is always null here: boards fetch current weather from\ + \ GET /api/agent/weather." properties: deviceConfig: $ref: "#/components/schemas/DeviceConfig" diff --git a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java index 9d0631b..198dc58 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java @@ -4,7 +4,9 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; +import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.security.CurrentUser; +import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; @@ -59,6 +61,28 @@ public OpenApiCustomizer defaultErrorResponseCustomizer() { }; } + /** + * No endpoint returns {@link MusallahBoardPayload} any more, but it is still the format of + * each {@code payloads/.json} inside a content package, and the board generates its + * types from this spec. Registered by hand so springdoc does not drop it as unreferenced. + * Schemas already present are left alone. + */ + @Bean + public OpenApiCustomizer contentPackageSchemasCustomizer() { + return openApi -> { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + ModelConverters.getInstance(true).readAll(MusallahBoardPayload.class) + .forEach((name, schema) -> { + if (openApi.getComponents().getSchemas() == null + || !openApi.getComponents().getSchemas().containsKey(name)) { + openApi.getComponents().addSchemas(name, schema); + } + }); + }; + } + @Bean public OpenAPI openAPI() { return new OpenAPI() diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java index 7a127cb..beaae99 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java @@ -1,12 +1,15 @@ package com.ibrasoft.lensbridge.controller; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.dto.board.request.ContentBundleRequest; +import com.ibrasoft.lensbridge.dto.board.response.AgentWeatherResponse; import com.ibrasoft.lensbridge.dto.board.response.SigningKeysResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Device; +import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator; import com.ibrasoft.lensbridge.service.board.offline.ContentSigningService; import com.ibrasoft.lensbridge.service.board.offline.OfflineBundle; @@ -35,6 +38,7 @@ import org.springframework.web.bind.annotation.RestController; import java.io.IOException; +import java.time.Instant; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -42,11 +46,11 @@ /** * Endpoints a board's agent calls on its own behalf once enrolled: fetching signed content - * for online sync, and fetching the content keys to pin. See MusallahBoard - * {@code agent/docs/architecture.md}, sections 9.1 to 9.3. + * for online sync, fetching the content keys to pin, and fetching current weather. See + * MusallahBoard {@code agent/docs/architecture.md}, sections 9.1 to 9.3. *

- * Both paths are {@code permitAll} in {@code WebSecurityConfig}. The signing keys are public - * by nature; the content bundle authenticates the device itself with + * All paths are {@code permitAll} in {@code WebSecurityConfig}. The signing keys are public + * by nature; the content bundle and weather authenticate the device themselves with * {@link DeviceRequestAuthenticator}, which needs the raw body and so cannot sit behind the * JWT filter chain. */ @@ -61,6 +65,7 @@ public class AgentContentController { private final DeviceRequestAuthenticator deviceAuthenticator; private final OfflineBundleService offlineBundleService; private final ContentSigningService signingService; + private final OpenWeatherService openWeatherService; private final ObjectMapper objectMapper; /** @@ -139,6 +144,53 @@ public ResponseEntity signingKeys() { return ResponseEntity.ok(new SigningKeysResponse(signingService.publicKeys())); } + /** + * Current weather for the board to overlay on its installed content. Weather is never in a + * content package (packages are built days ahead), so this is the one live fetch a board + * makes. A GET has no body, so the signature covers the SHA-256 of the empty string. + *

+ * Unavailable weather is a 200 with {@code "weather": null}, never a 5xx: a board without a + * forecast just hides the widget. + */ + @Operation(operationId = "getAgentWeather", + summary = "Current weather for this board", + description = "Device-authenticated with the X-MB-* headers, like the content bundle; the signed " + + "body hash is the SHA-256 of the empty string. `weather` is the OpenWeatherMap current " + + "weather JSON exactly as the server last fetched it, or null when the server has none. " + + "`fetchedAt` is when the server answered.") + @SecurityRequirements + @Parameters({ + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.DEVICE_ID_HEADER, required = true, + description = "Enrolled device id", schema = @Schema(type = "string", format = "uuid")), + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.TIMESTAMP_HEADER, required = true, + description = "Unix milliseconds; must be within 5 minutes of the server clock", + schema = @Schema(type = "integer", format = "int64")), + @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.SIGNATURE_HEADER, required = true, + description = "Base64 Ed25519 signature by the device key", schema = @Schema(type = "string")) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Current weather, or null weather when unavailable", + content = @Content(schema = @Schema(implementation = AgentWeatherResponse.class))), + @ApiResponse(responseCode = "401", description = "Missing or bad device signature, clock skew over 5 minutes, " + + "or an unknown or revoked device", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + }) + @GetMapping("/weather") + public ResponseEntity weather(HttpServletRequest request) { + deviceAuthenticator.authenticate(request, new byte[0]); + return ResponseEntity.ok(new AgentWeatherResponse(currentWeather(), Instant.now())); + } + + /** {@link OpenWeatherService#getCurrentWeather()} should never throw; if it does, no weather. */ + private JsonNode currentWeather() { + try { + return openWeatherService.getCurrentWeather(); + } catch (RuntimeException e) { + log.warn("Weather lookup failed; answering with null weather", e); + return null; + } + } + /** An absent or empty body means all defaults. Unknown fields are ignored (forward compatible). */ private ContentBundleRequest parse(byte[] body) { if (body == null || body.length == 0) { diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java index a7d8229..3bbd448 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/MusallahBoardController.java @@ -1,12 +1,10 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.BoardEvent; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.WeeklyContent; import com.ibrasoft.lensbridge.service.BoardService; -import com.ibrasoft.lensbridge.service.board.BoardPayloadAssembler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -24,7 +22,6 @@ public class MusallahBoardController { private final BoardService boardService; - private final BoardPayloadAssembler payloadAssembler; // ==================== Board Configuration ==================== @@ -77,11 +74,4 @@ public ResponseEntity> getEventsInRange( return ResponseEntity.ok(boardEvents); } - // ==================== Combined Payload ==================== - - @GetMapping("/payload") - public ResponseEntity getBoardPayload(@RequestParam UUID deviceId) { - log.debug("Musallah board fetching full payload for device: {}", deviceId); - return ResponseEntity.ok(payloadAssembler.assemble(deviceId)); - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentWeatherResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentWeatherResponse.java new file mode 100644 index 0000000..c65a211 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentWeatherResponse.java @@ -0,0 +1,33 @@ +package com.ibrasoft.lensbridge.dto.board.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * {@code GET /api/agent/weather}. Weather is the one thing a board still fetches live: it + * cannot go into a content package built days ahead. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AgentWeatherResponse { + + /** + * The OpenWeatherMap "current weather" response, forwarded verbatim, or null when the + * server has none (no API key, no successful fetch yet, or the weather service failed). + */ + @JsonInclude(JsonInclude.Include.ALWAYS) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + description = "OpenWeatherMap current weather JSON, verbatim; null when unavailable") + private JsonNode weather; + + /** When the server answered, as a UTC instant. */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-24T14:05:00Z") + private Instant fetchedAt; +} diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java index 49a6aae..c93586e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/MusallahBoardPayload.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,6 +11,14 @@ import java.util.List; +/** + * One day of a board's content: the file {@code payloads/.json} inside a signed + * content package, built by {@code BoardPayloadAssembler#assembleForDay}. No endpoint serves + * it directly; boards install packages and render from these files. + */ +@Schema(description = "Format of each payloads/.json file inside a signed content package (.mbu). " + + "Not returned by any endpoint; boards read it from installed packages. weather is always null " + + "here: boards fetch current weather from GET /api/agent/weather.") @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java index 6a0a210..3e4204e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java +++ b/src/main/java/com/ibrasoft/lensbridge/repository/sql/PosterRepository.java @@ -19,13 +19,9 @@ public interface PosterRepository extends JpaRepository { @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) ORDER BY p.startTime DESC") List findByAudienceOrBoth(@Param("aud") Audience audience); - @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startTime <= :now AND p.endTime > :now ORDER BY p.startTime DESC") - List findActivePostersForAudienceAt(@Param("now") Instant now, @Param("aud") Audience audience); - /** * Posters for the audience (or BOTH) active at any point in {@code [from, to)}: their - * {@code [startTime, endTime)} overlaps it. Used for offline bundle days; the live board - * uses {@link #findActivePostersForAudienceAt}. + * {@code [startTime, endTime)} overlaps it. Used for each day of a board's content package. */ @Query("SELECT p FROM Poster p WHERE (p.audience = :aud OR p.audience = com.ibrasoft.lensbridge.model.board.Audience.BOTH) AND p.startTime < :to AND p.endTime > :from ORDER BY p.startTime DESC") List findPostersForAudienceOverlapping(@Param("from") Instant from, @Param("to") Instant to, @Param("aud") Audience audience); diff --git a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java index c36f9fb..9971e28 100644 --- a/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/security/WebSecurityConfig.java @@ -95,6 +95,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // Public keys, and device-signed requests authenticated in the controller. .requestMatchers("/api/agent/signing-keys").permitAll() .requestMatchers("/api/agent/content-bundle").permitAll() + .requestMatchers("/api/agent/weather").permitAll() .requestMatchers("/api/dashboard/ws/**").permitAll() // Disable these endpoints in production; they are only for local dev and CI. .requestMatchers("/swagger-ui/**", "/swagger-ui.html", diff --git a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java index cdfc575..a18ab0b 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/PosterService.java @@ -59,15 +59,6 @@ public List getPostersForAudience(Audience audience) { return posterRepository.findByAudienceOrBoth(audience); } - /** - * Get active posters for a specific audience as FrameDefinitions. - * Returns posters that are currently active (startDate <= today < endDate) - * and match the audience or BOTH. - */ - public List getActivePosterFramesForAudience(Audience audience) { - return posterRepository.findActivePostersForAudienceAt(Instant.now(), audience); - } - /** * Posters for the audience (or BOTH) active at any point in {@code [from, to)}. */ diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java index 86932b8..b12f2b8 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardContext.java @@ -33,15 +33,12 @@ public class BoardContext { Device device; DeviceConfig config; - ZonedDateTime now; - /** - * True when this context stands for a whole calendar day rather than a live moment — the - * offline bundle assembles one payload per day, and a poster that starts at 14:00 must be - * in that day's payload even though {@link #now} is midnight. + * The moment this context is evaluated at, in the device's timezone. Contexts from + * {@link #of} stand for a whole calendar day and put this at its first instant; a poster + * that starts at 14:00 still belongs in that day's payload. */ - @Builder.Default - boolean wholeDay = false; + ZonedDateTime now; /** The device's timezone, already resolved into {@link #now}. */ public ZoneId zone() { @@ -88,26 +85,15 @@ public Instant nextDayStart() { } /** + * A context standing for all of {@code day} in the device's timezone: {@link #now} is that + * day's first instant and posters are those active at any point during it. Used to build + * content packages ahead of time. + * * @param defaultZone used when the device has no timezone configured or the configured * one is unparseable. Pass the deployment's default, not - * {@code ZoneId.systemDefault()} — the JVM's zone is an accident of + * {@code ZoneId.systemDefault()}: the JVM's zone is an accident of * the container image. */ - public static BoardContext of(Device device, ZoneId defaultZone) { - DeviceConfig config = device.getConfig(); - ZoneId zone = resolveZone(device, config, defaultZone); - return BoardContext.builder() - .device(device) - .config(config) - .now(ZonedDateTime.now(zone)) - .build(); - } - - /** - * A context standing for all of {@code day} in the device's timezone: {@link #now} is that - * day's first instant and posters are those active at any point during it. Used to build - * offline bundles ahead of time. - */ public static BoardContext of(Device device, ZoneId defaultZone, LocalDate day) { DeviceConfig config = device.getConfig(); ZoneId zone = resolveZone(device, config, defaultZone); @@ -115,7 +101,6 @@ public static BoardContext of(Device device, ZoneId defaultZone, LocalDate day) .device(device) .config(config) .now(day.atStartOfDay(zone)) - .wholeDay(true) .build(); } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java index 974b667..9aa8890 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssembler.java @@ -5,8 +5,6 @@ import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; -import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.board.producer.FrameProducer; import lombok.AllArgsConstructor; @@ -17,17 +15,17 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.List; -import java.util.UUID; import java.util.stream.Collectors; +/** + * Builds the per-day payload files inside a board's signed content package. Boards are + * local-first: they never fetch a live payload, they install these files and render them. + */ @Service @Slf4j @AllArgsConstructor public class BoardPayloadAssembler { - private final DeviceRepository deviceRepository; - private final OpenWeatherService openWeatherService; - /** * Every registered {@link FrameProducer}, Spring-ordered via {@code @Order} on each * implementation. @@ -36,37 +34,20 @@ public class BoardPayloadAssembler { private final ZoneId defaultZone; - public MusallahBoardPayload assemble(UUID deviceId) { - Device device = deviceRepository.findById(deviceId) - .orElseThrow(() -> new ApiResponseException( - HttpStatus.NOT_FOUND, - ErrorResponse.of("Device not found: " + deviceId))); - - BoardContext ctx = BoardContext.of(device, defaultZone); - - return MusallahBoardPayload.builder() - .deviceConfig(ctx.getConfig()) - .frames(produceFrames(ctx, true)) - .weather(openWeatherService.getCurrentWeather()) - .build(); - } - /** - * The payload {@link #assemble} would return at the start of {@code day} in the device's - * timezone, with posters widened to the whole day and no weather. Used for offline - * bundles, which are built days ahead — a forecast would be stale, and fetching one per - * day would burn the OpenWeather quota for nothing. + * The payload for all of {@code day} in the device's timezone, with posters widened to the + * whole day and no weather. Packages are built days ahead, so a forecast would be stale; + * boards fetch current weather separately from {@code GET /api/agent/weather}. *

- * Unlike the live path, a failing producer fails the whole call. A live board that loses - * a producer's slides gets them back on its next poll; a bundle carries the gap for - * weeks, and nobody is watching the board to notice. + * A failing producer fails the whole call. A package carries the gap for weeks, and + * nobody is watching the board to notice. */ public MusallahBoardPayload assembleForDay(Device device, LocalDate day) { BoardContext ctx = BoardContext.of(device, defaultZone, day); return MusallahBoardPayload.builder() .deviceConfig(ctx.getConfig()) - .frames(produceFrames(ctx, false)) + .frames(produceFrames(ctx)) .weather(null) .build(); } @@ -76,11 +57,9 @@ public ZoneId zoneFor(Device device) { return BoardContext.zoneFor(device, defaultZone); } - private List produceFrames(BoardContext ctx, boolean tolerateFailures) { + private List produceFrames(BoardContext ctx) { return frameProducers.stream() - .flatMap(producer -> (tolerateFailures - ? produceSafely(producer, ctx) - : produceStrictly(producer, ctx)).stream()) + .flatMap(producer -> produceStrictly(producer, ctx).stream()) .collect(Collectors.toList()); } @@ -99,22 +78,4 @@ private static List produceStrictly(FrameProducer producer, Boa throw failure; } } - - /** - * Runs one producer, absorbing anything it throws so the rest of the payload still ships. - *

- * These boards are unattended wall displays. A producer whose query fails should cost the - * board that producer's slides. A single producer failing should not make the entire API - * 500 - */ - private List produceSafely(FrameProducer producer, BoardContext ctx) { - try { - List frames = producer.produce(ctx); - return frames == null ? List.of() : frames; - } catch (RuntimeException e) { - log.error("Frame producer {} failed for device {}; omitting its frames from this payload", - producer.getClass().getSimpleName(), ctx.getDevice().getId(), e); - return List.of(); - } - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java index f59bcca..a6d9567 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java @@ -102,7 +102,11 @@ public class OfflineBundleService { private final BoardPayloadAssembler payloadAssembler; private final DeviceRepository deviceRepository; private final R2StorageService r2StorageService; - /** Spring's own mapper, so payload files serialize exactly like {@code /api/musallah/payload}. */ + /** + * Spring's own mapper, so each {@code payloads/.json} serializes a + * {@link com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload} with the same + * settings (ISO dates, naming) as the rest of the API. + */ private final ObjectMapper objectMapper; private final ContentSigningService signingService; private final Clock clock; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java index 6994c03..64e2231 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducer.java @@ -23,17 +23,12 @@ public class PosterFrameProducer implements FrameProducer { private final PosterService posterService; - /** - * A live board gets posters active right now. A whole-day context (an offline bundle day) - * gets every poster active at any point during that day in the device's zone. - */ + /** Every poster active at any point during the context's day in the device's zone. */ @Override public List produce(BoardContext ctx) { Audience audience = ctx.getDevice().getAudience(); - List posters = ctx.isWholeDay() - ? posterService.getPostersForAudienceOverlapping( - audience, ctx.currentDayStart(), ctx.nextDayStart()) - : posterService.getActivePosterFramesForAudience(audience); + List posters = posterService.getPostersForAudienceOverlapping( + audience, ctx.currentDayStart(), ctx.nextDayStart()); return posters.stream().map(p -> transform(p, ctx)).collect(Collectors.toList()); } diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java index de70a68..2ded90a 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java @@ -1,11 +1,13 @@ package com.ibrasoft.lensbridge.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.dto.board.response.SigningKeyView; import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Audience; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; +import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.UserService; import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService; import com.ibrasoft.lensbridge.service.agent.Ed25519TestUtil; @@ -31,6 +33,7 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.security.KeyPair; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -52,11 +55,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * {@code POST /api/agent/content-bundle} and {@code GET /api/agent/signing-keys}, through + * {@code POST /api/agent/content-bundle}, {@code GET /api/agent/weather} and + * {@code GET /api/agent/signing-keys}, through * MockMvc with the real {@link DeviceRequestAuthenticator}: requests are signed with a real * device key exactly as the agent signs them. *

- * Filters are off: both paths are {@code permitAll} in production, so the security chain has + * Filters are off: these paths are {@code permitAll} in production, so the security chain has * no say, and authentication is the controller's own job, which is what this exercises. */ @WebMvcTest(controllers = {AgentContentController.class, AgentEnrollmentController.class}) @@ -81,6 +85,8 @@ class AgentContentControllerTest { private DeviceRepository deviceRepository; @MockitoBean private DeviceEnrollmentService enrollmentService; + @MockitoBean + private OpenWeatherService openWeatherService; /** Needed by WebConfig's CurrentUserArgumentResolver; see BoardAdminControllerPermissionTest. */ @MockitoBean private UserService userService; @@ -238,6 +244,88 @@ void serviceErrorsKeepTheirStatusAndJsonMessage() throws Exception { .andExpect(jsonPath("$.message").value("Content signing is not configured on this server")); } + // ==================== weather ==================== + + private static final String WEATHER_URL = "/api/agent/weather"; + + /** GET signed as the agent signs it: no body, so the hash is of the empty string. */ + private MockHttpServletRequestBuilder signedWeatherGet(byte[] signedBody) { + long timestamp = System.currentTimeMillis(); + byte[] message = DeviceRequestAuthenticator.message("GET", WEATHER_URL, DEVICE_ID, timestamp, signedBody); + return get(WEATHER_URL) + .header(DeviceRequestAuthenticator.DEVICE_ID_HEADER, DEVICE_ID.toString()) + .header(DeviceRequestAuthenticator.TIMESTAMP_HEADER, Long.toString(timestamp)) + .header(DeviceRequestAuthenticator.SIGNATURE_HEADER, + Base64.getEncoder().encodeToString(Ed25519TestUtil.sign(keys.getPrivate(), message))); + } + + @Test + void weatherReturnsTheCachedWeatherVerbatimWithFetchedAt() throws Exception { + when(openWeatherService.getCurrentWeather()).thenReturn(new ObjectMapper().readTree( + "{\"name\":\"Mississauga\",\"main\":{\"temp\":12.5},\"weather\":[{\"icon\":\"04d\"}]}")); + Instant before = Instant.now(); + + MvcResult result = mockMvc.perform(signedWeatherGet(new byte[0])) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather.name").value("Mississauga")) + .andExpect(jsonPath("$.weather.main.temp").value(12.5)) + .andExpect(jsonPath("$.weather.weather[0].icon").value("04d")) + .andReturn(); + + String fetchedAt = new ObjectMapper().readTree(result.getResponse().getContentAsString()) + .get("fetchedAt").asText(); + assertThat(fetchedAt).endsWith("Z"); + assertThat(Instant.parse(fetchedAt)).isBetween(before, Instant.now()); + } + + @Test + void weatherIsAnExplicitNullWhenTheServerHasNone() throws Exception { + when(openWeatherService.getCurrentWeather()).thenReturn(null); + + mockMvc.perform(signedWeatherGet(new byte[0])) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").hasJsonPath()) + .andExpect(jsonPath("$.weather").doesNotExist()) + .andExpect(jsonPath("$.fetchedAt").isString()); + } + + @Test + void weatherIsNullNotA5xxWhenTheServiceFails() throws Exception { + when(openWeatherService.getCurrentWeather()).thenThrow(new IllegalStateException("upstream down")); + + mockMvc.perform(signedWeatherGet(new byte[0])) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weather").hasJsonPath()) + .andExpect(jsonPath("$.weather").doesNotExist()) + .andExpect(jsonPath("$.fetchedAt").isString()); + } + + @Test + void weatherWithoutHeadersIsA401() throws Exception { + mockMvc.perform(get(WEATHER_URL)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); + verifyNoInteractions(openWeatherService); + } + + /** The signature must cover the empty body; one over any other body is rejected. */ + @Test + void weatherWithABadSignatureIsA401() throws Exception { + mockMvc.perform(signedWeatherGet("{}".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Device authentication failed: bad signature")); + verifyNoInteractions(openWeatherService); + } + + @Test + void weatherForARevokedDeviceIsA401() throws Exception { + device.setRevokedAt(Instant.now()); + mockMvc.perform(signedWeatherGet(new byte[0])) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Device authentication failed: unknown or revoked device")); + verifyNoInteractions(openWeatherService); + } + // ==================== signing-keys and enrollment ==================== @Test diff --git a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java index af160b1..bc1e28b 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/PosterServiceTest.java @@ -105,15 +105,6 @@ void getPostersForAudienceFiltersByAudienceOrBoth() { assertThat(service.getPostersForAudience(Audience.SISTERS)).isEqualTo(expected); } - @Test - void getActivePosterFramesForAudienceQueriesActiveForAudience() { - List expected = List.of(poster("a")); - when(posterRepository.findActivePostersForAudienceAt(any(Instant.class), any(Audience.class))) - .thenReturn(expected); - - assertThat(service.getActivePosterFramesForAudience(Audience.BROTHERS)).isEqualTo(expected); - } - @Test void getActivePostersQueriesActiveAtNow() { List expected = List.of(poster("a")); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java index 99ac963..97e9b2d 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardContextTest.java @@ -22,6 +22,8 @@ class BoardContextTest { private static final ZonedDateTime WEDNESDAY = ZonedDateTime.of(2026, 5, 13, 12, 34, 0, 0, ZoneOffset.UTC); + private static final LocalDate DAY = LocalDate.of(2026, 5, 13); + private static Device deviceWithTimezone(String timezone) { Location location = Location.builder().timezone(timezone).build(); DeviceConfig config = DeviceConfig.builder().location(location).build(); @@ -43,7 +45,7 @@ void zoneIsTheZoneOfNow() { void ofResolvesZoneFromConfigTimezone() { Device device = deviceWithTimezone("Asia/Dubai"); - BoardContext ctx = BoardContext.of(device, DEFAULT_ZONE); + BoardContext ctx = BoardContext.of(device, DEFAULT_ZONE, DAY); assertThat(ctx.getDevice()).isSameAs(device); assertThat(ctx.getConfig()).isSameAs(device.getConfig()); @@ -52,7 +54,7 @@ void ofResolvesZoneFromConfigTimezone() { @Test void ofFallsBackToSuppliedDefaultWhenConfigMissing() { - BoardContext ctx = BoardContext.of(Device.builder().displayName("d").build(), DEFAULT_ZONE); + BoardContext ctx = BoardContext.of(Device.builder().displayName("d").build(), DEFAULT_ZONE, DAY); assertThat(ctx.getConfig()).isNull(); assertThat(ctx.zone()).isEqualTo(DEFAULT_ZONE); @@ -60,7 +62,7 @@ void ofFallsBackToSuppliedDefaultWhenConfigMissing() { @Test void ofFallsBackToSuppliedDefaultWhenTimezoneInvalid() { - assertThat(BoardContext.of(deviceWithTimezone("Bogus/Zone"), DEFAULT_ZONE).zone()) + assertThat(BoardContext.of(deviceWithTimezone("Bogus/Zone"), DEFAULT_ZONE, DAY).zone()) .isEqualTo(DEFAULT_ZONE); } @@ -68,7 +70,7 @@ void ofFallsBackToSuppliedDefaultWhenTimezoneInvalid() { void ofUsesTheSuppliedDefaultRatherThanTheJvmZone() { ZoneId elsewhere = ZoneId.of("Asia/Tokyo"); - assertThat(BoardContext.of(deviceWithTimezone(null), elsewhere).zone()).isEqualTo(elsewhere); + assertThat(BoardContext.of(deviceWithTimezone(null), elsewhere, DAY).zone()).isEqualTo(elsewhere); } @Test @@ -170,24 +172,13 @@ void todayIsTheLocalDateInTheDeviceZone() { assertThat(ctx.today()).isEqualTo(LocalDate.of(2026, 5, 13)); } - // ==================== Whole-day contexts ==================== - - @Test - void builtContextIsNotWholeDay() { - assertThat(at(WEDNESDAY).isWholeDay()).isFalse(); - } - - @Test - void ofLiveIsNotWholeDay() { - assertThat(BoardContext.of(deviceWithTimezone("Asia/Dubai"), DEFAULT_ZONE).isWholeDay()).isFalse(); - } + // ==================== Day contexts ==================== @Test void ofDayStartsAtMidnightInTheDeviceZoneAndSpansTheDay() { BoardContext ctx = BoardContext.of(deviceWithTimezone("Asia/Dubai"), DEFAULT_ZONE, LocalDate.of(2026, 5, 13)); - assertThat(ctx.isWholeDay()).isTrue(); assertThat(ctx.getNow()).isEqualTo(ZonedDateTime.of(2026, 5, 13, 0, 0, 0, 0, ZoneId.of("Asia/Dubai"))); assertThat(ctx.today()).isEqualTo(LocalDate.of(2026, 5, 13)); assertThat(ctx.currentDayStart()).isEqualTo(Instant.parse("2026-05-12T20:00:00Z")); diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java index 2e02410..c50fdf4 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/BoardPayloadAssemblerTest.java @@ -6,8 +6,6 @@ import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.model.board.frames.FrameDefinition; import com.ibrasoft.lensbridge.model.board.frames.FrameType; -import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.board.producer.FrameProducer; import org.junit.jupiter.api.Test; @@ -17,38 +15,27 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** - * Covers the assembler's fault isolation: a broken producer costs the board its own slides and - * nothing else. See {@code produceSafely}. + * Covers {@link BoardPayloadAssembler#assembleForDay}: producers run in order against a + * whole-day context, and a failing producer fails the day rather than shipping a gap. */ class BoardPayloadAssemblerTest { private static final ZoneId DEFAULT_ZONE = ZoneId.of("America/Toronto"); private static final UUID DEVICE_ID = UUID.randomUUID(); + private static final LocalDate DAY = LocalDate.of(2026, 9, 24); - private final DeviceRepository deviceRepository = mock(DeviceRepository.class); - private final OpenWeatherService weatherService = mock(OpenWeatherService.class); - - private BoardPayloadAssembler assemblerWith(FrameProducer... producers) { - Device device = Device.builder() - .id(DEVICE_ID) - .displayName("test-board") - .audience(Audience.BOTH) - .build(); - when(deviceRepository.findById(DEVICE_ID)).thenReturn(Optional.of(device)); + private static Device device() { + return Device.builder().id(DEVICE_ID).displayName("test-board").audience(Audience.BOTH).build(); + } - return new BoardPayloadAssembler( - deviceRepository, weatherService, List.of(producers), DEFAULT_ZONE); + private static BoardPayloadAssembler assemblerWith(FrameProducer... producers) { + return new BoardPayloadAssembler(List.of(producers), DEFAULT_ZONE); } /** A producer that emits one frame carrying the given id, so order is checkable. */ @@ -72,120 +59,59 @@ private static List frameIdsOf(MusallahBoardPayload payload) { } @Test - void aFailingProducerIsSkippedAndTheRestOfThePayloadSurvives() { - BoardPayloadAssembler assembler = assemblerWith( - emitting("first"), - throwing(new IllegalStateException("poster query blew up")), - emitting("third")); + void assembleForDayRunsProducersAtThatDaysMidnightWithoutWeather() { + List seen = new ArrayList<>(); + BoardPayloadAssembler assembler = assemblerWith(ctx -> { + seen.add(ctx); + return List.of(); + }, emitting("x")); - MusallahBoardPayload payload = assembler.assemble(DEVICE_ID); + MusallahBoardPayload payload = assembler.assembleForDay(device(), DAY); - assertThat(frameIdsOf(payload)).containsExactly("first", "third"); + assertThat(frameIdsOf(payload)).containsExactly("x"); + assertThat(payload.getWeather()).isNull(); + assertThat(seen).singleElement().satisfies(ctx -> + assertThat(ctx.getNow()).isEqualTo(ZonedDateTime.of(2026, 9, 24, 0, 0, 0, 0, DEFAULT_ZONE))); } - /** Producers run in injected order; dropping one must not reshuffle the survivors. */ + /** Producers run in injected order. */ @Test - void survivingFramesKeepTheirProducerOrder() { - BoardPayloadAssembler assembler = assemblerWith( - throwing(new RuntimeException("boom")), - emitting("a"), - emitting("b"), - throwing(new RuntimeException("boom")), - emitting("c")); + void framesKeepTheirProducerOrder() { + BoardPayloadAssembler assembler = assemblerWith(emitting("a"), emitting("b"), emitting("c")); - assertThat(frameIdsOf(assembler.assemble(DEVICE_ID))).containsExactly("a", "b", "c"); + assertThat(frameIdsOf(assembler.assembleForDay(device(), DAY))).containsExactly("a", "b", "c"); } - /** A null return is a producer bug, not a reason to blank the board. */ + /** A null return is a producer bug, not a reason to blank the day. */ @Test void aProducerReturningNullIsTreatedAsEmpty() { BoardPayloadAssembler assembler = assemblerWith(ctx -> null, emitting("only")); - assertThat(frameIdsOf(assembler.assemble(DEVICE_ID))).containsExactly("only"); + assertThat(frameIdsOf(assembler.assembleForDay(device(), DAY))).containsExactly("only"); } /** - * Total producer failure still yields a payload. The board gets its config and can render - * its own empty state, which beats a 500 it has no way to display. + * A package carries its gaps for weeks with nobody watching, so a failing producer fails + * the whole day. */ @Test - void everyProducerFailingStillReturnsAPayload() { + void assembleForDayPropagatesAProducerFailure() { BoardPayloadAssembler assembler = assemblerWith( - throwing(new RuntimeException("boom")), - throwing(new IllegalArgumentException("also boom"))); + emitting("a"), throwing(new IllegalStateException("poster query blew up"))); - MusallahBoardPayload payload = assembler.assemble(DEVICE_ID); - - assertThat(payload).isNotNull(); - assertThat(payload.getFrames()).isEmpty(); + assertThatThrownBy(() -> assembler.assembleForDay(device(), DAY)) + .isInstanceOf(ApiResponseException.class) + .hasCauseInstanceOf(IllegalStateException.class); } - /** - * An {@link Error} is not a missing slide. Swallowing it would log an OOM as a skipped - * frame and keep serving payloads from a JVM that should be failing loudly. - */ + /** An {@link Error} is not wrapped as a producer failure. */ @Test - void errorsStillPropagate() { + void errorsPropagateUnwrapped() { BoardPayloadAssembler assembler = assemblerWith(ctx -> { throw new StackOverflowError("recursion in a producer"); }); - assertThatThrownBy(() -> assembler.assemble(DEVICE_ID)) + assertThatThrownBy(() -> assembler.assembleForDay(device(), DAY)) .isInstanceOf(StackOverflowError.class); } - - /** Degradation is for producers only — an unknown device is still a 404. */ - @Test - void anUnknownDeviceStillFails() { - UUID missing = UUID.randomUUID(); - when(deviceRepository.findById(any())).thenReturn(Optional.empty()); - - BoardPayloadAssembler assembler = new BoardPayloadAssembler( - deviceRepository, weatherService, List.of(emitting("unused")), DEFAULT_ZONE); - - assertThatThrownBy(() -> assembler.assemble(missing)) - .isInstanceOf(ApiResponseException.class); - } - - // ==================== assembleForDay ==================== - - private static Device device() { - return Device.builder().id(DEVICE_ID).displayName("test-board").audience(Audience.BOTH).build(); - } - - @Test - void assembleForDayRunsProducersAtThatDaysMidnightWithoutWeather() { - List seen = new ArrayList<>(); - BoardPayloadAssembler assembler = new BoardPayloadAssembler( - deviceRepository, weatherService, List.of(ctx -> { - seen.add(ctx); - return List.of(); - }, emitting("x")), DEFAULT_ZONE); - - MusallahBoardPayload payload = assembler.assembleForDay(device(), LocalDate.of(2026, 9, 24)); - - assertThat(frameIdsOf(payload)).containsExactly("x"); - assertThat(payload.getWeather()).isNull(); - assertThat(seen).singleElement().satisfies(ctx -> { - assertThat(ctx.isWholeDay()).isTrue(); - assertThat(ctx.getNow()).isEqualTo(ZonedDateTime.of(2026, 9, 24, 0, 0, 0, 0, DEFAULT_ZONE)); - }); - verifyNoInteractions(weatherService); - } - - /** - * A bundle carries its gaps for weeks with nobody watching, so the lenient per-producer - * isolation of the live path does not apply. - */ - @Test - void assembleForDayPropagatesAProducerFailure() { - BoardPayloadAssembler assembler = new BoardPayloadAssembler( - deviceRepository, weatherService, - List.of(emitting("a"), throwing(new IllegalStateException("poster query blew up"))), - DEFAULT_ZONE); - - assertThatThrownBy(() -> assembler.assembleForDay(device(), LocalDate.of(2026, 9, 24))) - .isInstanceOf(ApiResponseException.class) - .hasCauseInstanceOf(IllegalStateException.class); - } } diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java index 6c9c5d5..eafbdf9 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java @@ -11,7 +11,6 @@ import com.ibrasoft.lensbridge.model.board.Poster; import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig; import com.ibrasoft.lensbridge.repository.sql.DeviceRepository; -import com.ibrasoft.lensbridge.service.OpenWeatherService; import com.ibrasoft.lensbridge.service.PosterService; import com.ibrasoft.lensbridge.service.R2StorageService; import com.ibrasoft.lensbridge.service.board.BoardContext; @@ -76,7 +75,6 @@ class OfflineBundleServiceTest { private static final byte[] PNG = "png-bytes".getBytes(StandardCharsets.UTF_8); private final DeviceRepository deviceRepository = mock(DeviceRepository.class); - private final OpenWeatherService weatherService = mock(OpenWeatherService.class); private final PosterService posterService = mock(PosterService.class); private final R2StorageService r2 = mock(R2StorageService.class); private final ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json() @@ -128,7 +126,6 @@ private OfflineBundleService service() { return List.of(); }; BoardPayloadAssembler assembler = new BoardPayloadAssembler( - deviceRepository, weatherService, List.of(recorder, new PosterFrameProducer(posterService)), ZoneId.of("UTC")); return new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, signer, Clock.fixed(NOW, ZoneOffset.UTC)); @@ -345,7 +342,7 @@ void defaultWindowOfFourteenDaysHasFourteenPayloads() throws Exception { void withoutASigningKeyTheBuildIsA503AndNothingIsAssembledOrFetched() { poster("Halaqa", "poster-a.jpg", "2026-10-01T00:00", "2026-12-01T00:00"); BoardPayloadAssembler assembler = new BoardPayloadAssembler( - deviceRepository, weatherService, List.of(new PosterFrameProducer(posterService)), ZoneId.of("UTC")); + List.of(new PosterFrameProducer(posterService)), ZoneId.of("UTC")); OfflineBundleService unsigned = new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, new ContentSigningService("", ""), Clock.fixed(NOW, ZoneOffset.UTC)); @@ -434,17 +431,16 @@ void eachDayIsAssembledAtTheStartOfThatDayInTheDeviceZone() { } @Test - void weatherIsNullAndOpenWeatherIsNeverCalled() throws Exception { + void weatherIsAlwaysNull() throws Exception { Map zip = unzip(service().build(DEVICE_ID, 2)); JsonNode payload = json(zip, "payloads/2026-10-30.json"); assertThat(payload.has("weather")).isTrue(); assertThat(payload.get("weather").isNull()).isTrue(); - verifyNoInteractions(weatherService); } @Test - void payloadIsSerializedLikeTheLiveEndpoint() throws Exception { + void payloadIsSerializedInThePerDayFormat() throws Exception { Map zip = unzip(service().build(DEVICE_ID, 1)); JsonNode payload = json(zip, "payloads/2026-10-30.json"); @@ -581,13 +577,6 @@ void anUnfetchableImageFailsTheWholeExportWith502NamingThePoster() throws Except // ==================== Validation ==================== - @Test - void theLivePosterQueryIsNeverUsedForABundle() { - service().build(DEVICE_ID, 3); - - verify(posterService, never()).getActivePosterFramesForAudience(any()); - } - @Test void daysOutsideOneToThirtyOneIsA400() { for (int days : new int[]{0, -1, 32}) { diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java index 516dcd8..99f5b1f 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/producer/PosterFrameProducerTest.java @@ -18,14 +18,10 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; -import java.time.ZonedDateTime; import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -89,22 +85,9 @@ private static Device device() { return Device.builder().id(UUID.randomUUID()).displayName("d").audience(Audience.SISTERS).build(); } - /** The online board's behaviour is unchanged: posters active at this instant, nothing wider. */ + /** A package day: anything active at any point between this midnight and the next. */ @Test - void liveContextAsksForPostersActiveRightNow() { - ZonedDateTime now = ZonedDateTime.of(2026, 5, 13, 15, 0, 0, 0, TORONTO); - BoardContext ctx = BoardContext.builder().device(device()).now(now).build(); - Poster p = poster("Now", "u", 10); - when(posterService.getActivePosterFramesForAudience(Audience.SISTERS)).thenReturn(List.of(p)); - - assertThat(transformer.produce(ctx)).extracting(FrameDefinition::getFrameId) - .containsExactly("poster:" + p.getId()); - verify(posterService, never()).getPostersForAudienceOverlapping(any(), any(), any()); - } - - /** An offline bundle day: anything active at any point between this midnight and the next. */ - @Test - void wholeDayContextAsksForPostersOverlappingTheDay() { + void asksForPostersOverlappingTheContextsDay() { BoardContext ctx = BoardContext.of(device(), TORONTO, LocalDate.of(2026, 5, 13)); Poster p = poster("Today", "u", 10); when(posterService.getPostersForAudienceOverlapping(Audience.SISTERS, @@ -113,6 +96,5 @@ void wholeDayContextAsksForPostersOverlappingTheDay() { assertThat(transformer.produce(ctx)).extracting(FrameDefinition::getFrameId) .containsExactly("poster:" + p.getId()); - verify(posterService, never()).getActivePosterFramesForAudience(any()); } } From ee937a279cea5663452ba0dfb9eb001d728da23e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:31:49 +0000 Subject: [PATCH 3/6] Device auth as @AuthenticatedDevice instead of hand-parsed request bodies Device-authenticated agent endpoints now read like any other controller: public void contentBundle(@AuthenticatedDevice Device device, @Valid @RequestBody(required = false) ContentBundleRequest body, ...) - DeviceBodyCachingFilter reads /api/agent/ request bodies (at most 1 MiB) so the signature can be checked over the exact bytes and Spring can still bind @RequestBody afterwards - DeviceAuthInterceptor verifies the signature before any argument is resolved, so an unauthenticated request is always a 401, never a 400 - AuthenticatedDeviceArgumentResolver hands the verified Device to the controller, mirroring CurrentUserArgumentResolver - ContentBundleRequest validates itself with bean validation - An OperationCustomizer documents the X-MB-* headers and the 401 once for every @AuthenticatedDevice endpoint - Tests: unsigned malformed body is 401; oversized body is 413 The wire protocol is unchanged; agents need no update. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .../lensbridge/config/OpenApiConfig.java | 45 ++++++++ .../ibrasoft/lensbridge/config/WebConfig.java | 16 +++ .../controller/AgentContentController.java | 103 ++---------------- .../board/request/ContentBundleRequest.java | 29 +++-- .../agent/http/AuthenticatedDevice.java | 25 +++++ .../AuthenticatedDeviceArgumentResolver.java | 34 ++++++ .../service/agent/http/CachedBodyRequest.java | 60 ++++++++++ .../agent/http/DeviceAuthInterceptor.java | 65 +++++++++++ .../agent/http/DeviceBodyCachingFilter.java | 48 ++++++++ .../AgentContentControllerTest.java | 35 +++++- 10 files changed, 359 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDevice.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDeviceArgumentResolver.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/CachedBodyRequest.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceAuthInterceptor.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java diff --git a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java index 198dc58..fd91f3a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/OpenApiConfig.java @@ -6,6 +6,12 @@ import io.swagger.v3.oas.models.security.SecurityRequirement; import com.ibrasoft.lensbridge.dto.board.response.MusallahBoardPayload; import com.ibrasoft.lensbridge.security.CurrentUser; +import com.ibrasoft.lensbridge.service.agent.http.AuthenticatedDevice; +import com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.HeaderParameter; +import org.springdoc.core.customizers.OperationCustomizer; import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; @@ -20,6 +26,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Arrays; import java.util.List; @Configuration @@ -28,6 +35,8 @@ public class OpenApiConfig { static { // Workaround for @CurrentUser being an argument, which springdoc cannot infer SpringDocUtils.getConfig().addAnnotationsToIgnore(CurrentUser.class); + // And @AuthenticatedDevice, which deviceAuthCustomizer documents as headers instead. + SpringDocUtils.getConfig().addAnnotationsToIgnore(AuthenticatedDevice.class); // Likewise Pageable, which is documented as a single object-typed query // parameter instead of the page/size/sort trio the resolver actually reads. @@ -39,6 +48,42 @@ public class OpenApiConfig { @Value("${lensbridge.app.version:unknown}") private String appVersion; + /** + * Documents the device signature once for every endpoint that takes an + * {@link AuthenticatedDevice}: the three X-MB-* headers and the 401. The controllers stay + * free of the same twenty lines of annotations each. + */ + @Bean + public OperationCustomizer deviceAuthCustomizer() { + return (operation, handlerMethod) -> { + boolean deviceAuthenticated = Arrays.stream(handlerMethod.getMethodParameters()) + .anyMatch(p -> p.hasParameterAnnotation(AuthenticatedDevice.class)); + if (!deviceAuthenticated) { + return operation; + } + operation.addParametersItem(deviceHeader(DeviceRequestAuthenticator.DEVICE_ID_HEADER, + "Enrolled device id", new StringSchema().format("uuid"))); + operation.addParametersItem(deviceHeader(DeviceRequestAuthenticator.TIMESTAMP_HEADER, + "Unix milliseconds; must be within 5 minutes of the server clock", + new IntegerSchema().format("int64"))); + operation.addParametersItem(deviceHeader(DeviceRequestAuthenticator.SIGNATURE_HEADER, + "Base64 Ed25519 signature by the device key over musallahboard-http-v1, the method, " + + "path, device id, timestamp and the body's SHA-256", + new StringSchema())); + operation.getResponses().addApiResponse("401", new ApiResponse() + .description("Missing or bad device signature, clock skew over 5 minutes, " + + "or an unknown or revoked device") + .content(new Content().addMediaType( + org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(new Schema<>().$ref("#/components/schemas/MessageResponse"))))); + return operation; + }; + } + + private static HeaderParameter deviceHeader(String name, String description, Schema schema) { + return (HeaderParameter) new HeaderParameter().name(name).required(true).description(description).schema(schema); + } + @Bean public OpenApiCustomizer defaultErrorResponseCustomizer() { return openApi -> { diff --git a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java index b84f5b6..2e21d93 100644 --- a/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java +++ b/src/main/java/com/ibrasoft/lensbridge/config/WebConfig.java @@ -3,6 +3,8 @@ import java.util.List; import com.ibrasoft.lensbridge.security.CurrentUserArgumentResolver; +import com.ibrasoft.lensbridge.service.agent.http.AuthenticatedDeviceArgumentResolver; +import com.ibrasoft.lensbridge.service.agent.http.DeviceAuthInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -11,6 +13,7 @@ import org.springframework.lang.NonNull; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @@ -28,6 +31,12 @@ public class WebConfig implements WebMvcConfigurer { @Autowired private CurrentUserArgumentResolver currentUserArgumentResolver; + @Autowired + private AuthenticatedDeviceArgumentResolver authenticatedDeviceArgumentResolver; + + @Autowired + private DeviceAuthInterceptor deviceAuthInterceptor; + @Bean public FilterRegistrationBean rateLimitingFilterRegistration() { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); @@ -40,6 +49,13 @@ public FilterRegistrationBean rateLimitingFilterRegistration @Override public void addArgumentResolvers(@NonNull List resolvers) { resolvers.add(currentUserArgumentResolver); + resolvers.add(authenticatedDeviceArgumentResolver); + } + + /** Device-authenticated agent endpoints; see {@code @AuthenticatedDevice}. */ + @Override + public void addInterceptors(@NonNull InterceptorRegistry registry) { + registry.addInterceptor(deviceAuthInterceptor).addPathPatterns("/api/agent/**"); } @Override diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java index beaae99..f96e7ef 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java @@ -1,30 +1,24 @@ package com.ibrasoft.lensbridge.controller; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; import com.ibrasoft.lensbridge.dto.board.request.ContentBundleRequest; import com.ibrasoft.lensbridge.dto.board.response.AgentWeatherResponse; import com.ibrasoft.lensbridge.dto.board.response.SigningKeysResponse; -import com.ibrasoft.lensbridge.exception.ApiResponseException; import com.ibrasoft.lensbridge.model.board.Device; import com.ibrasoft.lensbridge.service.OpenWeatherService; -import com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator; +import com.ibrasoft.lensbridge.service.agent.http.AuthenticatedDevice; import com.ibrasoft.lensbridge.service.board.offline.ContentSigningService; import com.ibrasoft.lensbridge.service.board.offline.OfflineBundle; import com.ibrasoft.lensbridge.service.board.offline.OfflineBundleService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirements; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ContentDisposition; @@ -39,10 +33,6 @@ import java.io.IOException; import java.time.Instant; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Pattern; /** * Endpoints a board's agent calls on its own behalf once enrolled: fetching signed content @@ -60,19 +50,14 @@ @Slf4j public class AgentContentController { - private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$"); - - private final DeviceRequestAuthenticator deviceAuthenticator; private final OfflineBundleService offlineBundleService; private final ContentSigningService signingService; private final OpenWeatherService openWeatherService; - private final ObjectMapper objectMapper; /** - * The body is taken as raw bytes and parsed here, after authentication, so the signature - * check hashes exactly what the device sent. Order of checks: device auth (401), then the - * body (400), then building (404/409 in theory, 503 without a signing key, 502 for an - * unfetchable poster). + * The device is authenticated before the body is even bound (see {@link AuthenticatedDevice}), + * so the order of checks is: device (401), body (400), building (503 without a signing key, + * 502 for an unfetchable poster). *

* Like the admin download, the package is built completely and then written straight to * the servlet response, so every failure is still a JSON error rather than a truncated zip. @@ -89,42 +74,23 @@ public class AgentContentController { + "timezone. Media whose SHA-256 is in haveMedia is listed and signed in mbu.json but " + "left out of the zip.") @SecurityRequirements - @Parameters({ - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.DEVICE_ID_HEADER, required = true, - description = "Enrolled device id", schema = @Schema(type = "string", format = "uuid")), - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.TIMESTAMP_HEADER, required = true, - description = "Unix milliseconds; must be within 5 minutes of the server clock", - schema = @Schema(type = "integer", format = "int64")), - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.SIGNATURE_HEADER, required = true, - description = "Base64 Ed25519 signature by the device key", schema = @Schema(type = "string")) - }) - @io.swagger.v3.oas.annotations.parameters.RequestBody(required = false, content = @Content( - mediaType = "application/json", schema = @Schema(implementation = ContentBundleRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "The signed content package", content = @Content(mediaType = OfflineBundleService.MBU_CONTENT_TYPE, schema = @Schema(type = "string", format = "binary"))), @ApiResponse(responseCode = "400", description = "Malformed body, days outside 1-31, or a bad haveMedia entry", content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "401", description = "Missing or bad device signature, clock skew over 5 minutes, " - + "or an unknown or revoked device", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), @ApiResponse(responseCode = "502", description = "A poster image could not be fetched; the message names the poster", content = @Content(schema = @Schema(implementation = MessageResponse.class))), @ApiResponse(responseCode = "503", description = "No content signing key is configured on the server", content = @Content(schema = @Schema(implementation = MessageResponse.class))) }) @PostMapping("/content-bundle") - public void contentBundle(@RequestBody(required = false) byte[] body, - HttpServletRequest request, + public void contentBundle(@AuthenticatedDevice Device device, + @Valid @RequestBody(required = false) ContentBundleRequest body, HttpServletResponse response) throws IOException { - Device device = deviceAuthenticator.authenticate(request, body); - ContentBundleRequest parsed = parse(body); - - int days = parsed.getDays() == null ? ContentBundleRequest.DEFAULT_DAYS : parsed.getDays(); - Set haveMedia = haveMedia(parsed.getHaveMedia()); - - OfflineBundle bundle = offlineBundleService.build(device.getId(), days, haveMedia); + ContentBundleRequest request = body == null ? new ContentBundleRequest() : body; + OfflineBundle bundle = offlineBundleService.build(device.getId(), request.daysOrDefault(), request.haveMediaSet()); response.setStatus(HttpStatus.OK.value()); response.setContentType(OfflineBundleService.MBU_CONTENT_TYPE); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, @@ -147,7 +113,7 @@ public ResponseEntity signingKeys() { /** * Current weather for the board to overlay on its installed content. Weather is never in a * content package (packages are built days ahead), so this is the one live fetch a board - * makes. A GET has no body, so the signature covers the SHA-256 of the empty string. + * makes. *

* Unavailable weather is a 200 with {@code "weather": null}, never a 5xx: a board without a * forecast just hides the widget. @@ -159,25 +125,12 @@ public ResponseEntity signingKeys() { + "weather JSON exactly as the server last fetched it, or null when the server has none. " + "`fetchedAt` is when the server answered.") @SecurityRequirements - @Parameters({ - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.DEVICE_ID_HEADER, required = true, - description = "Enrolled device id", schema = @Schema(type = "string", format = "uuid")), - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.TIMESTAMP_HEADER, required = true, - description = "Unix milliseconds; must be within 5 minutes of the server clock", - schema = @Schema(type = "integer", format = "int64")), - @Parameter(in = ParameterIn.HEADER, name = DeviceRequestAuthenticator.SIGNATURE_HEADER, required = true, - description = "Base64 Ed25519 signature by the device key", schema = @Schema(type = "string")) - }) @ApiResponses({ @ApiResponse(responseCode = "200", description = "Current weather, or null weather when unavailable", - content = @Content(schema = @Schema(implementation = AgentWeatherResponse.class))), - @ApiResponse(responseCode = "401", description = "Missing or bad device signature, clock skew over 5 minutes, " - + "or an unknown or revoked device", - content = @Content(schema = @Schema(implementation = MessageResponse.class))) + content = @Content(schema = @Schema(implementation = AgentWeatherResponse.class))) }) @GetMapping("/weather") - public ResponseEntity weather(HttpServletRequest request) { - deviceAuthenticator.authenticate(request, new byte[0]); + public ResponseEntity weather(@AuthenticatedDevice Device device) { return ResponseEntity.ok(new AgentWeatherResponse(currentWeather(), Instant.now())); } @@ -190,36 +143,4 @@ private JsonNode currentWeather() { return null; } } - - /** An absent or empty body means all defaults. Unknown fields are ignored (forward compatible). */ - private ContentBundleRequest parse(byte[] body) { - if (body == null || body.length == 0) { - return new ContentBundleRequest(); - } - try { - ContentBundleRequest parsed = objectMapper.readValue(body, ContentBundleRequest.class); - return parsed == null ? new ContentBundleRequest() : parsed; - } catch (IOException e) { - throw badRequest("Request body must be a JSON object like {\"days\": 7, \"haveMedia\": []}"); - } - } - - private static Set haveMedia(List hashes) { - if (hashes == null) return Set.of(); - if (hashes.size() > ContentBundleRequest.MAX_HAVE_MEDIA) { - throw badRequest("haveMedia may list at most " + ContentBundleRequest.MAX_HAVE_MEDIA + " hashes"); - } - Set set = new LinkedHashSet<>(); - for (String hash : hashes) { - if (hash == null || !SHA256_HEX.matcher(hash).matches()) { - throw badRequest("haveMedia entries must be 64 lowercase hex characters (SHA-256)"); - } - set.add(hash); - } - return set; - } - - private static ApiResponseException badRequest(String message) { - return new ApiResponseException(HttpStatus.BAD_REQUEST, new MessageResponse(message)); - } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java index 0192db3..30f76ba 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java @@ -2,31 +2,46 @@ import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** - * Body of {@code POST /api/agent/content-bundle}. Both fields are optional. - *

- * Parsed by hand from the raw request bytes rather than bound by Spring, because the device - * signature covers the exact bytes that were sent; see - * {@link com.ibrasoft.lensbridge.service.agent.http.DeviceRequestAuthenticator}. + * Body of {@code POST /api/agent/content-bundle}. Both fields are optional; an absent body + * means all defaults. Unknown fields are ignored, so newer agents can send more. */ @Data public class ContentBundleRequest { public static final int DEFAULT_DAYS = 7; public static final int MAX_HAVE_MEDIA = 2000; + private static final String SHA256_HEX = "^[0-9a-f]{64}$"; /** Days in the package, starting today in the device's timezone. */ @Schema(minimum = "1", maximum = "31", defaultValue = "" + DEFAULT_DAYS) + @Min(1) + @Max(31) private Integer days; /** * SHA-256 (64 lowercase hex) of media the board's store already holds. Those files are left * out of the zip but stay listed, and signed, in {@code mbu.json}. */ - @ArraySchema(maxItems = MAX_HAVE_MEDIA, schema = @Schema(pattern = "^[0-9a-f]{64}$")) - private List haveMedia; + @ArraySchema(maxItems = MAX_HAVE_MEDIA, schema = @Schema(pattern = SHA256_HEX)) + @Size(max = MAX_HAVE_MEDIA) + private List<@Pattern(regexp = SHA256_HEX, message = "must be 64 lowercase hex characters (SHA-256)") String> haveMedia; + + public int daysOrDefault() { + return days == null ? DEFAULT_DAYS : days; + } + + public Set haveMediaSet() { + return haveMedia == null ? Set.of() : new LinkedHashSet<>(haveMedia); + } } diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDevice.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDevice.java new file mode 100644 index 0000000..6e78d4d --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDevice.java @@ -0,0 +1,25 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a controller parameter of type {@link com.ibrasoft.lensbridge.model.board.Device} as + * "the board that signed this request". An endpoint that declares one is device-authenticated: + * {@link DeviceAuthInterceptor} verifies the X-MB-* signature before anything else about the + * request is looked at, and answers 401 if it does not verify. The endpoint itself never sees + * an unauthenticated request. + * + *

+ * @GetMapping("/weather")
+ * public AgentWeatherResponse weather(@AuthenticatedDevice Device device) { ... }
+ * 
+ */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AuthenticatedDevice { +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDeviceArgumentResolver.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDeviceArgumentResolver.java new file mode 100644 index 0000000..3c23890 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/AuthenticatedDeviceArgumentResolver.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import com.ibrasoft.lensbridge.model.board.Device; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * Supplies {@link AuthenticatedDevice} parameters with the device {@link DeviceAuthInterceptor} + * already authenticated. It never authenticates anything itself. + */ +@Component +public class AuthenticatedDeviceArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticatedDevice.class) + && Device.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Object device = webRequest.getAttribute(DeviceAuthInterceptor.DEVICE_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + if (device == null) { + throw new IllegalStateException("DeviceAuthInterceptor did not run for " + parameter.getExecutable()); + } + return device; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/CachedBodyRequest.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/CachedBodyRequest.java new file mode 100644 index 0000000..85b3f7f --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/CachedBodyRequest.java @@ -0,0 +1,60 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * A request whose body has been read once, up front, so it can be read again: once by + * {@link DeviceAuthInterceptor} to check the signature over the exact bytes sent, and once by + * Spring to bind {@code @RequestBody}. Created by {@link DeviceBodyCachingFilter}. + */ +public class CachedBodyRequest extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequest(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + /** The body exactly as received. */ + public byte[] body() { + return body; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream in = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override public int read() { return in.read(); } + @Override public int read(byte[] b, int off, int len) { return in.read(b, off, len); } + @Override public boolean isFinished() { return in.available() == 0; } + @Override public boolean isReady() { return true; } + @Override public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException("cached body is read synchronously"); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceAuthInterceptor.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceAuthInterceptor.java new file mode 100644 index 0000000..a9a4853 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceAuthInterceptor.java @@ -0,0 +1,65 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import com.ibrasoft.lensbridge.model.board.Device; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.WebUtils; + +import java.util.Arrays; + +/** + * Authenticates device requests before Spring resolves any controller argument, for every + * endpoint with an {@link AuthenticatedDevice} parameter. Doing it here rather than in the + * argument resolver means the check can never depend on parameter order: an unauthenticated + * request gets a 401, never a 400 that reveals something about the body format. + */ +@Component +@RequiredArgsConstructor +public class DeviceAuthInterceptor implements HandlerInterceptor { + + /** Request attribute holding the authenticated {@link Device}. */ + static final String DEVICE_ATTRIBUTE = DeviceAuthInterceptor.class.getName() + ".device"; + + private static final byte[] NO_BODY = new byte[0]; + + /** + * Looked up on first use rather than injected: every web context loads this interceptor + * (through WebConfig), including test slices that have no device endpoints and no + * repositories to build an authenticator from. + */ + private final ObjectProvider authenticator; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (handler instanceof HandlerMethod method && requiresDevice(method)) { + Device device = authenticator.getObject().authenticate(request, bodyOf(request)); + request.setAttribute(DEVICE_ATTRIBUTE, device); + } + return true; + } + + static boolean requiresDevice(HandlerMethod method) { + return Arrays.stream(method.getMethodParameters()) + .anyMatch(p -> p.hasParameterAnnotation(AuthenticatedDevice.class)); + } + + /** + * The body the signature covers. Every {@code /api/agent/} request passes through + * {@link DeviceBodyCachingFilter}; a device endpoint anywhere else would have no cached + * body, and that is a programming error worth failing loudly on rather than silently + * signing over nothing. + */ + private static byte[] bodyOf(HttpServletRequest request) { + CachedBodyRequest cached = WebUtils.getNativeRequest(request, CachedBodyRequest.class); + if (cached == null) { + throw new IllegalStateException("Device-authenticated endpoint " + request.getRequestURI() + + " is outside " + DeviceBodyCachingFilter.AGENT_PATH_PREFIX + ", so its body was not cached"); + } + return cached.body().length == 0 ? NO_BODY : cached.body(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java new file mode 100644 index 0000000..e0683b0 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java @@ -0,0 +1,48 @@ +package com.ibrasoft.lensbridge.service.agent.http; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Reads the body of every {@code /api/agent/} request into memory so the device signature can + * be checked over the exact bytes and Spring can still bind the body afterwards (see + * {@link CachedBodyRequest}). Agent request bodies are small JSON (the largest, a content + * bundle request listing 2000 media hashes, is about 140 KB), so anything over + * {@link #MAX_BODY_BYTES} is refused outright rather than buffered. + */ +@Component +public class DeviceBodyCachingFilter extends OncePerRequestFilter { + + static final String AGENT_PATH_PREFIX = "/api/agent/"; + static final int MAX_BODY_BYTES = 1024 * 1024; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(AGENT_PATH_PREFIX); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + byte[] body; + try (InputStream in = request.getInputStream()) { + body = in.readNBytes(MAX_BODY_BYTES + 1); + } + if (body.length > MAX_BODY_BYTES) { + response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"message\":\"Request body is larger than 1 MiB\"}"); + return; + } + chain.doFilter(new CachedBodyRequest(request, body), response); + } +} diff --git a/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java index 2ded90a..3848d5c 100644 --- a/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/controller/AgentContentControllerTest.java @@ -27,6 +27,11 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import com.ibrasoft.lensbridge.service.agent.http.AuthenticatedDeviceArgumentResolver; +import com.ibrasoft.lensbridge.service.agent.http.DeviceAuthInterceptor; +import com.ibrasoft.lensbridge.service.agent.http.DeviceBodyCachingFilter; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -60,12 +65,14 @@ * MockMvc with the real {@link DeviceRequestAuthenticator}: requests are signed with a real * device key exactly as the agent signs them. *

- * Filters are off: these paths are {@code permitAll} in production, so the security chain has - * no say, and authentication is the controller's own job, which is what this exercises. + * The security chain is off (these paths are {@code permitAll}; device authentication is not + * Spring Security's job). MockMvc is built by hand so the one filter that matters, + * {@link DeviceBodyCachingFilter}, still runs, together with the real interceptor and resolver. */ @WebMvcTest(controllers = {AgentContentController.class, AgentEnrollmentController.class}) @AutoConfigureMockMvc(addFilters = false) -@Import({DeviceRequestAuthenticator.class, Ed25519Verifier.class}) +@Import({DeviceRequestAuthenticator.class, Ed25519Verifier.class, DeviceAuthInterceptor.class, + AuthenticatedDeviceArgumentResolver.class, DeviceBodyCachingFilter.class}) @TestPropertySource(properties = "musallahboard.agent.websocketUrl=wss://example.test/api/agent/ws") class AgentContentControllerTest { @@ -75,6 +82,10 @@ class AgentContentControllerTest { private static final String SHA_B = "0123456789abcdef".repeat(4); @Autowired + private WebApplicationContext context; + @Autowired + private DeviceBodyCachingFilter bodyCachingFilter; + private MockMvc mockMvc; @MockitoBean @@ -96,6 +107,7 @@ class AgentContentControllerTest { @BeforeEach void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).addFilters(bodyCachingFilter).build(); keys = Ed25519TestUtil.generate(); device = Device.builder() .id(DEVICE_ID) @@ -234,6 +246,23 @@ void malformedJsonIsA400() throws Exception { verifyNoInteractions(offlineBundleService); } + @Test + void anUnsignedMalformedBodyIsA401NotA400() throws Exception { + // Authentication runs before the body is bound, so an unauthenticated caller learns + // nothing about the body format. + mockMvc.perform(post(URL).contentType(MediaType.APPLICATION_JSON).content("{\"days\": ")) + .andExpect(status().isUnauthorized()); + verifyNoInteractions(offlineBundleService); + } + + @Test + void aBodyOverOneMebibyteIsRefusedBeforeAuthentication() throws Exception { + byte[] huge = new byte[1024 * 1024 + 1]; + mockMvc.perform(signedPost(URL, URL, huge, huge, System.currentTimeMillis())) + .andExpect(status().isPayloadTooLarge()); + verifyNoInteractions(offlineBundleService); + } + @Test void serviceErrorsKeepTheirStatusAndJsonMessage() throws Exception { when(offlineBundleService.build(any(), anyInt(), any())).thenThrow(new ApiResponseException( From 31cf4690ac8403556d96e88a90cf8810a6caf9b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:23:36 +0000 Subject: [PATCH 4/6] Derive the content public key with BouncyCastle, not a JDK workaround The JDK has no API for "public key of this Ed25519 seed", so the signing service fed the seed to a key pair generator through a fake SecureRandom and then signed and verified a probe message at startup to prove the trick worked. That read like test scaffolding in production code. BouncyCastle does it directly: Ed25519PrivateKeyParameters(seed) .generatePublicKey(), and Ed25519Signer for signing. The fake RNG, the startup self-test and the hand-built PKCS#8/X.509 DER prefixes are gone. Keys, key ids and signatures are unchanged (the cross-language test vector still passes). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- pom.xml | 7 + .../board/offline/ContentSigningService.java | 132 ++---------------- 2 files changed, 20 insertions(+), 119 deletions(-) diff --git a/pom.xml b/pom.xml index 856f179..aa3c7cc 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,13 @@ + + + org.bouncycastle + bcprov-jdk18on + 1.80 + org.springframework.boot spring-boot-starter-web diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java index b72c90b..25e5119 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/ContentSigningService.java @@ -4,24 +4,15 @@ import com.ibrasoft.lensbridge.dto.upload.response.ErrorResponse; import com.ibrasoft.lensbridge.exception.ApiResponseException; import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.KeyFactory; -import java.security.KeyPair; -import java.security.KeyPairGenerator; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.SecureRandom; -import java.security.Signature; -import java.security.spec.NamedParameterSpec; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -62,23 +53,13 @@ public class ContentSigningService { private static final int SEED_LEN = 32; private static final int RAW_PUBLIC_KEY_LEN = 32; - /** - * DER prefix of a PKCS#8 PrivateKeyInfo for Ed25519 (RFC 8410): version 0, the Ed25519 - * AlgorithmIdentifier, and an OCTET STRING wrapping the 32-byte seed as another OCTET - * STRING. Appending the seed gives a complete encoding the JDK's KeyFactory accepts. - */ - private static final byte[] PKCS8_PREFIX = HexFormat.of().parseHex("302e020100300506032b657004220420"); - - /** DER prefix of an Ed25519 SubjectPublicKeyInfo; the raw key is the 32 bytes after it. */ - private static final byte[] X509_PREFIX = HexFormat.of().parseHex("302a300506032b6570032100"); - /** The configured signing key, or null when signing is not configured. */ private final SigningKey current; /** Every public content key to publish, current first. Empty when not configured. */ private final List publishedKeys; - private record SigningKey(PrivateKey privateKey, byte[] rawPublicKey, String keyId) {} + private record SigningKey(Ed25519PrivateKeyParameters privateKey, byte[] rawPublicKey, String keyId) {} /** * @param privateKeyBase64 base64 of the 32-byte seed; empty or blank disables signing. @@ -185,43 +166,6 @@ public static String keyId(byte[] rawPublicKey) { } } - /** - * Derives the raw 32-byte public key for a 32-byte seed. - *

- * The JDK has no public API for "public key of this private key". Its Ed25519 key pair - * generator, though, takes the seed as the first 32 bytes it reads from the supplied - * {@link SecureRandom}, so feeding it the seed reproduces the pair. The constructor then - * proves the pair matches by signing and verifying a probe message, so a JDK that ever - * drew its randomness differently would fail loudly at startup rather than publish a - * public key that does not match the signatures. - */ - static byte[] rawPublicKeyForSeed(byte[] seed) throws GeneralSecurityException { - KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); - generator.initialize(NamedParameterSpec.ED25519, new FixedSeedRandom(seed)); - KeyPair pair = generator.generateKeyPair(); - return rawPublicKey(pair.getPublic()); - } - - /** The raw 32-byte key out of an Ed25519 public key's X.509 encoding. */ - static byte[] rawPublicKey(PublicKey publicKey) { - byte[] encoded = publicKey.getEncoded(); - if (encoded == null || encoded.length != X509_PREFIX.length + RAW_PUBLIC_KEY_LEN - || !Arrays.equals(encoded, 0, X509_PREFIX.length, X509_PREFIX, 0, X509_PREFIX.length)) { - throw new IllegalStateException("Unexpected Ed25519 public key encoding"); - } - return Arrays.copyOfRange(encoded, X509_PREFIX.length, encoded.length); - } - - /** Wraps a 32-byte seed in the PKCS#8 envelope and decodes it with the JDK provider. */ - static PrivateKey privateKeyForSeed(byte[] seed) throws GeneralSecurityException { - byte[] pkcs8 = new byte[PKCS8_PREFIX.length + seed.length]; - System.arraycopy(PKCS8_PREFIX, 0, pkcs8, 0, PKCS8_PREFIX.length); - System.arraycopy(seed, 0, pkcs8, PKCS8_PREFIX.length, seed.length); - return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(pkcs8)); - } - - // ==================== Loading ==================== - private static SigningKey loadSigningKey(String base64) { byte[] seed; try { @@ -234,20 +178,10 @@ private static SigningKey loadSigningKey(String base64) { throw new IllegalStateException("musallahboard.content-signing.private-key must be the base64 of a " + SEED_LEN + "-byte Ed25519 seed, got " + seed.length + " bytes"); } - try { - PrivateKey privateKey = privateKeyForSeed(seed); - byte[] rawPublicKey = rawPublicKeyForSeed(seed); - // Prove the derived public key belongs to this private key before publishing it. - byte[] probe = "musallahboard content key self-test".getBytes(StandardCharsets.US_ASCII); - if (!verify(rawPublicKey, probe, sign(privateKey, probe))) { - throw new IllegalStateException("Derived content public key does not match the private key"); - } - return new SigningKey(privateKey, rawPublicKey, keyId(rawPublicKey)); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Could not load the content signing key: " + e.getMessage(), e); - } finally { - Arrays.fill(seed, (byte) 0); - } + Ed25519PrivateKeyParameters privateKey = new Ed25519PrivateKeyParameters(seed, 0); + Arrays.fill(seed, (byte) 0); + byte[] rawPublicKey = privateKey.generatePublicKey().getEncoded(); + return new SigningKey(privateKey, rawPublicKey, keyId(rawPublicKey)); } private static List parsePreviousKeys(String csv) { @@ -278,55 +212,15 @@ private static SigningKeyView view(byte[] rawPublicKey) { // ==================== Primitives ==================== - private static byte[] sign(PrivateKey privateKey, byte[] message) { - try { - // Signature objects are not thread-safe; one per call is cheap for Ed25519. - Signature signer = Signature.getInstance("Ed25519"); - signer.initSign(privateKey); - signer.update(message); - return signer.sign(); - } catch (GeneralSecurityException e) { - throw new IllegalStateException("Ed25519 signing failed", e); - } - } - - private static boolean verify(byte[] rawPublicKey, byte[] message, byte[] signature) - throws GeneralSecurityException { - byte[] x509 = new byte[X509_PREFIX.length + rawPublicKey.length]; - System.arraycopy(X509_PREFIX, 0, x509, 0, X509_PREFIX.length); - System.arraycopy(rawPublicKey, 0, x509, X509_PREFIX.length, rawPublicKey.length); - PublicKey publicKey = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(x509)); - Signature verifier = Signature.getInstance("Ed25519"); - verifier.initVerify(publicKey); - verifier.update(message); - return verifier.verify(signature); + private static byte[] sign(Ed25519PrivateKeyParameters privateKey, byte[] message) { + // Signers hold state; one per call is cheap for Ed25519. + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, privateKey); + signer.update(message, 0, message.length); + return signer.generateSignature(); } private static boolean isBlank(String s) { return s == null || s.isBlank(); } - - /** - * A {@link SecureRandom} that yields the given seed and then refuses to produce more, so - * {@link #rawPublicKeyForSeed} fails instead of silently mixing in other bytes if the - * generator ever asks for more than one seed's worth. - */ - private static final class FixedSeedRandom extends SecureRandom { - private final byte[] seed; - private boolean used; - - FixedSeedRandom(byte[] seed) { - this.seed = seed.clone(); - } - - @Override - public void nextBytes(byte[] bytes) { - if (used || bytes.length != seed.length) { - throw new IllegalStateException("Ed25519 key generator requested unexpected randomness"); - } - System.arraycopy(seed, 0, bytes, 0, seed.length); - Arrays.fill(seed, (byte) 0); - used = true; - } - } } From b23bf1b1b4111baf32555c49c6f07c9ca1758910 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:29:40 +0000 Subject: [PATCH 5/6] Review fixes: prefix-safe body caching, millisecond sequence, clearer docs - DeviceBodyCachingFilter matches the path within the application, like the interceptor's /api/agent/** pattern, so a context path or X-Forwarded-Prefix cannot leave device requests without a cached body - Content package sequence keeps the build time's milliseconds, so two builds in the same second are ordered (createdAt stays whole seconds) - ContentBundleRequest reuses OfflineBundleService.MAX_DAYS, and both DEFAULT_DAYS constants say why they differ (7 for sync, 14 for visits) - DeviceRequestAuthenticator and AgentContentController javadocs describe the @AuthenticatedDevice flow instead of the old hand-parsed body Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .../controller/AgentContentController.java | 5 ++--- .../dto/board/request/ContentBundleRequest.java | 10 ++++++++-- .../agent/http/DeviceBodyCachingFilter.java | 10 +++++++++- .../agent/http/DeviceRequestAuthenticator.java | 8 ++++---- .../service/board/offline/MbuManifest.java | 3 ++- .../board/offline/OfflineBundleService.java | 12 ++++++++++-- .../board/offline/OfflineBundleServiceTest.java | 16 +++++++++++++++- 7 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java index f96e7ef..d5bf2e3 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentContentController.java @@ -40,9 +40,8 @@ * MusallahBoard {@code agent/docs/architecture.md}, sections 9.1 to 9.3. *

* All paths are {@code permitAll} in {@code WebSecurityConfig}. The signing keys are public - * by nature; the content bundle and weather authenticate the device themselves with - * {@link DeviceRequestAuthenticator}, which needs the raw body and so cannot sit behind the - * JWT filter chain. + * by nature; the content bundle and weather take an {@link AuthenticatedDevice}, which + * verifies the board's Ed25519 request signature instead of a user's JWT. */ @RestController @RequestMapping("/api/agent") diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java index 30f76ba..4221620 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/ContentBundleRequest.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.dto.board.request; +import com.ibrasoft.lensbridge.service.board.offline.OfflineBundleService; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; @@ -19,14 +20,19 @@ @Data public class ContentBundleRequest { + /** + * An online board syncs every 30 minutes, so a week is plenty of runway for an outage and + * keeps each sync small. Admin downloads for offline boards default to two weeks instead + * ({@link OfflineBundleService#DEFAULT_DAYS}). + */ public static final int DEFAULT_DAYS = 7; public static final int MAX_HAVE_MEDIA = 2000; private static final String SHA256_HEX = "^[0-9a-f]{64}$"; /** Days in the package, starting today in the device's timezone. */ - @Schema(minimum = "1", maximum = "31", defaultValue = "" + DEFAULT_DAYS) + @Schema(minimum = "1", maximum = "" + OfflineBundleService.MAX_DAYS, defaultValue = "" + DEFAULT_DAYS) @Min(1) - @Max(31) + @Max(OfflineBundleService.MAX_DAYS) private Integer days; /** diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java index e0683b0..31fad1e 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceBodyCachingFilter.java @@ -8,6 +8,7 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UrlPathHelper; import java.io.IOException; import java.io.InputStream; @@ -25,9 +26,16 @@ public class DeviceBodyCachingFilter extends OncePerRequestFilter { static final String AGENT_PATH_PREFIX = "/api/agent/"; static final int MAX_BODY_BYTES = 1024 * 1024; + private static final UrlPathHelper PATHS = new UrlPathHelper(); + + /** + * Matched on the path inside the application, exactly as the interceptor's + * {@code /api/agent/**} pattern is, so a context path or a proxy prefix + * ({@code X-Forwarded-Prefix}) cannot make the two disagree. + */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { - return !request.getRequestURI().startsWith(AGENT_PATH_PREFIX); + return !PATHS.getPathWithinApplication(request).startsWith(AGENT_PATH_PREFIX); } @Override diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java index 0f58c10..4365ea2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/http/DeviceRequestAuthenticator.java @@ -41,10 +41,10 @@ * ({@code musallahboard-auth-v1}) and from package signatures ({@code musallahboard-mbu-v2}), * so a signature made for one can never be replayed as another. *

- * Deliberately a component the endpoint calls, not a servlet filter: only a couple of agent - * endpoints use it, and the caller hands over the raw body bytes it already read, so the - * hash covers exactly what was sent rather than a re-serialization of a bound object. The - * paths are {@code permitAll} in {@code WebSecurityConfig}; this is their authentication. + * Endpoints do not call this directly: they declare an {@link AuthenticatedDevice} parameter, + * and {@link DeviceAuthInterceptor} calls it with the body {@link DeviceBodyCachingFilter} + * cached, so the hash covers exactly the bytes that were sent. The paths are + * {@code permitAll} in {@code WebSecurityConfig}; this is their authentication. *

* Replays inside the five-minute window are accepted. That is by design: the endpoints this * guards are idempotent reads, so a replay only fetches the same content again. diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java index 65f9bcd..56df5c1 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/MbuManifest.java @@ -20,7 +20,8 @@ * @param formatVersion always {@value OfflineBundleService#FORMAT_VERSION} * @param type always {@code "content"}: this backend only holds a content key * @param createdAt RFC 3339 UTC, second precision ({@code 2026-09-24T14:02:11Z}) - * @param sequence {@code createdAt} in epoch milliseconds; the board's anti-rollback counter + * @param sequence build time in epoch milliseconds (not truncated like {@code createdAt}); + * the board's anti-rollback counter * @param deviceId the device the content is bound to * @param version always null for content (only software packages carry a version) * @param files every payload and media file, including media left out of this zip diff --git a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java index a6d9567..e4de601 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleService.java @@ -63,6 +63,11 @@ public class OfflineBundleService { public static final int FORMAT_VERSION = 2; + /** + * Default window of an admin download for an offline board: two weeks between visits. + * Online boards ask for their own window (a week by default, see + * {@link com.ibrasoft.lensbridge.dto.board.request.ContentBundleRequest#DEFAULT_DAYS}). + */ public static final int DEFAULT_DAYS = 14; public static final int MAX_DAYS = 31; @@ -174,7 +179,10 @@ public OfflineBundle build(UUID deviceId, int days, Set haveMedia) { signingService.requireConfigured(); ZoneId zone = payloadAssembler.zoneFor(device); - Instant createdAt = clock.instant().truncatedTo(ChronoUnit.SECONDS); + // The sequence keeps the clock's milliseconds so two builds in the same second still + // order; createdAt is the same instant at the second precision the format uses. + Instant builtAt = clock.instant(); + Instant createdAt = builtAt.truncatedTo(ChronoUnit.SECONDS); LocalDate firstDay = LocalDate.ofInstant(createdAt, zone); LocalDate lastDay = firstDay.plusDays(days - 1L); @@ -217,7 +225,7 @@ public OfflineBundle build(UUID deviceId, int days, Set haveMedia) { FORMAT_VERSION, "content", createdAt.toString(), - createdAt.toEpochMilli(), + builtAt.toEpochMilli(), deviceId.toString(), null, files, diff --git a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java index eafbdf9..6a15ee7 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/board/offline/OfflineBundleServiceTest.java @@ -121,6 +121,10 @@ void setUp() throws IOException { } private OfflineBundleService service() { + return service(NOW); + } + + private OfflineBundleService service(Instant now) { FrameProducer recorder = ctx -> { seenContexts.add(ctx); return List.of(); @@ -128,7 +132,7 @@ private OfflineBundleService service() { BoardPayloadAssembler assembler = new BoardPayloadAssembler( List.of(recorder, new PosterFrameProducer(posterService)), ZoneId.of("UTC")); return new OfflineBundleService(assembler, deviceRepository, r2, objectMapper, signer, - Clock.fixed(NOW, ZoneOffset.UTC)); + Clock.fixed(now, ZoneOffset.UTC)); } private static Instant toronto(String localDateTime) { @@ -262,6 +266,16 @@ void manifestCarriesTheContractFields() throws Exception { assertThat(mediaFile.get("bytes").asLong()).isEqualTo(JPEG.length); } + /** Two builds in the same second must still be ordered, or the board would treat the second as unchanged. */ + @Test + void sequenceKeepsMillisecondsWhileCreatedAtIsWholeSeconds() throws Exception { + JsonNode first = json(unzip(service(NOW.plusMillis(100)).build(DEVICE_ID, 1)), "mbu.json"); + JsonNode second = json(unzip(service(NOW.plusMillis(900)).build(DEVICE_ID, 1)), "mbu.json"); + assertThat(first.get("createdAt").asText()).isEqualTo("2026-10-31T03:30:00Z"); + assertThat(second.get("createdAt").asText()).isEqualTo("2026-10-31T03:30:00Z"); + assertThat(second.get("sequence").asLong()).isGreaterThan(first.get("sequence").asLong()); + } + /** Every zip entry but mbu.json and mbu.sig is listed with its true hash and size, and vice versa. */ @Test void filesListsEveryPayloadAndMediaFileWithItsHashAndSize() throws Exception { From 79d5d4b5befd7b2583bcdd35520efb0f13b7875c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:41:08 +0000 Subject: [PATCH 6/6] Remove board back-compat: heartbeat alias, legacy refresh test, optional keys Nothing on the board side has been deployed, so: - HeartbeatFrame no longer accepts the old displayedFrameId name - The "legacy REFRESH substring" test is gone; no kiosk parses that way - contentSigningKeys is a required field of the enrollment response (an empty list without a key) instead of optional for older agents Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- openapi.yaml | 7 ++++--- .../lensbridge/dto/board/agent/HeartbeatFrame.java | 3 --- .../dto/board/response/AgentEnrollResponse.java | 6 ++++-- .../lensbridge/handler/AgentWebSocketHandlerTest.java | 4 ++-- .../lensbridge/handler/BoardStreamHandlerTest.java | 10 ---------- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 3ed5675..1c88fea 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3370,12 +3370,13 @@ components: - token AgentEnrollResponse: type: object + required: + - contentSigningKeys properties: contentSigningKeys: type: array - description: "Public content signing keys the agent pins at enrollment. Empty\ - \ when the server has no content key configured. Optional so agents built\ - \ before it existed still decode the response." + description: "Public content signing keys the agent pins at enrollment. Always\ + \ present; empty when the server has no content key configured." items: $ref: "#/components/schemas/SigningKeyView" deviceId: diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java index ccdc0f3..7417538 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java @@ -1,6 +1,5 @@ package com.ibrasoft.lensbridge.dto.board.agent; -import com.fasterxml.jackson.annotation.JsonAlias; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; @@ -34,8 +33,6 @@ public static class Telemetry { */ private String agentVersion; - // JSON alias for back-compat. will be removed in a future release. - @JsonAlias("displayedFrameId") private String displayedFrameKey; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java index 86fb878..4f39212 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/AgentEnrollResponse.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.dto.board.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @@ -17,8 +18,9 @@ public class AgentEnrollResponse { /** * Public content signing keys the agent pins at enrollment, over the same TLS connection - * that establishes its identity. Empty when the server has no content key configured. - * Optional in the spec so agents built before it existed still decode the response. + * that establishes its identity. Always present; empty when the server has no content + * key configured. */ + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private List contentSigningKeys; } diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java index 5803821..bd8131a 100644 --- a/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/handler/AgentWebSocketHandlerTest.java @@ -216,7 +216,7 @@ void heartbeat_persistsViaService() throws Exception { * Regression: the agent reports the kiosk's slide key ("next-prayer", "poster-3"), which * is not a UUID. While the field was typed UUID every heartbeat from a working board * failed to parse and the session was closed with 4004, so devices looked permanently - * offline. The legacy displayedFrameId name must keep working. + * offline. */ @Test void heartbeat_acceptsNonUuidFrameKey() throws Exception { @@ -230,7 +230,7 @@ void heartbeat_acceptsNonUuidFrameKey() throws Exception { ArgumentCaptor captor = ArgumentCaptor.forClass(HeartbeatFrame.class); deliver(s, String.format( "{\"type\":\"heartbeat\",\"seq\":2,\"sessionId\":\"%s\"," + - "\"telemetry\":{\"kioskAlive\":true,\"displayedFrameId\":\"next-prayer\"}}", + "\"telemetry\":{\"kioskAlive\":true,\"displayedFrameKey\":\"next-prayer\"}}", hello.sessionId)); verify(heartbeatService).record(eq(device.getId()), captor.capture(), any()); diff --git a/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java b/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java index 4a6b91f..6903383 100644 --- a/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/handler/BoardStreamHandlerTest.java @@ -74,16 +74,6 @@ void messageIsJsonCarryingTypeReasonAndTarget() throws Exception { assertThat(body.hasNonNull("at")).isTrue(); } - /** The deployed kiosk substring-matches rather than parsing, so JSON must still hit it. */ - @Test - void messageStillMatchesTheLegacyRefreshSubstring() throws Exception { - Session s = connect(enrolled()); - - handler.refreshAll(); - - assertThat(s.sent.get(0).toUpperCase()).contains("REFRESH"); - } - // ── enrollment is a precondition, not a state ───────────────────────── @Test