diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java new file mode 100644 index 00000000..2de1abf0 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/web/ArtifactDownloadTokenSigner.java @@ -0,0 +1,69 @@ +package com.cameleer.server.app.web; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.UUID; + +/** + * HMAC-SHA256 signed URL tokens for artifact downloads. Key derivation is + * deterministic from the JWT signing secret (HMAC-SHA256(jwtSecret, + * "cameleer-artifact-token-v1")) so server restarts don't invalidate fresh + * tokens. The loader container does NOT carry the JWT or the bootstrap token — + * it only carries the signed URL, which is scoped to one appVersionId and one + * short TTL. + */ +public class ArtifactDownloadTokenSigner { + + private static final String DERIVATION = "cameleer-artifact-token-v1"; + + public record SignedToken(long exp, String sig) {} + + private final byte[] key; + private final Clock clock; + + public ArtifactDownloadTokenSigner(String jwtSecret, Clock clock) { + this.key = deriveKey(jwtSecret); + this.clock = clock; + } + + private static byte[] deriveKey(String jwtSecret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(DERIVATION.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("HMAC init failed", e); + } + } + + public SignedToken sign(UUID appVersionId, Duration ttl) { + long exp = clock.instant().plus(ttl).getEpochSecond(); + return new SignedToken(exp, signRaw(appVersionId, exp)); + } + + public String signRaw(UUID appVersionId, long exp) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + String payload = appVersionId + ":" + exp; + byte[] tag = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tag); + } catch (Exception e) { + throw new IllegalStateException("sign failed", e); + } + } + + public boolean verify(UUID appVersionId, long exp, String sig) { + if (sig == null || sig.isBlank()) return false; + if (clock.instant().getEpochSecond() > exp) return false; + String expected = signRaw(appVersionId, exp); + // constant-time compare + return java.security.MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + sig.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java new file mode 100644 index 00000000..7c76f048 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/web/ArtifactDownloadTokenSignerTest.java @@ -0,0 +1,50 @@ +package com.cameleer.server.app.web; + +import org.junit.jupiter.api.Test; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArtifactDownloadTokenSignerTest { + + private final String secret = "test-secret-do-not-use"; + private final Instant now = Instant.parse("2026-04-27T10:00:00Z"); + private final Clock clock = Clock.fixed(now, ZoneOffset.UTC); + + @Test + void signedTokenVerifiesWithinTtl() { + var signer = new ArtifactDownloadTokenSigner(secret, clock); + UUID id = UUID.randomUUID(); + var token = signer.sign(id, Duration.ofMinutes(5)); + assertThat(signer.verify(id, token.exp(), token.sig())).isTrue(); + } + + @Test + void rejectsTamperedSignature() { + var signer = new ArtifactDownloadTokenSigner(secret, clock); + UUID id = UUID.randomUUID(); + var token = signer.sign(id, Duration.ofMinutes(5)); + assertThat(signer.verify(id, token.exp(), token.sig() + "x")).isFalse(); + } + + @Test + void rejectsMismatchedAppVersionId() { + var signer = new ArtifactDownloadTokenSigner(secret, clock); + UUID id = UUID.randomUUID(); + var token = signer.sign(id, Duration.ofMinutes(5)); + assertThat(signer.verify(UUID.randomUUID(), token.exp(), token.sig())).isFalse(); + } + + @Test + void rejectsExpired() { + var signer = new ArtifactDownloadTokenSigner(secret, clock); + UUID id = UUID.randomUUID(); + long pastExp = now.minusSeconds(1).getEpochSecond(); + String sig = signer.signRaw(id, pastExp); + assertThat(signer.verify(id, pastExp, sig)).isFalse(); + } +}