feat(web): add HMAC token signer for artifact downloads
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user