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