feat(web): add HMAC token signer for artifact downloads

This commit is contained in:
hsiegeln
2026-04-27 15:25:57 +02:00
parent d90cd5ef2d
commit 25bbd759d0
2 changed files with 119 additions and 0 deletions

View File

@@ -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));
}
}

View File

@@ -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();
}
}