diff --git a/README.md b/README.md index aeed2845..7be4f191 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ flowchart LR | Service | Routing | Notable operations | |-------------------------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Blob Storage** | `/{account}/` | Create/delete containers, upload/download/delete blobs, list blobs; ADLS Gen2 DFS host alias and user delegation key vending | +| **Blob Storage** | `/{account}/` | Create/delete containers, upload/download/delete blobs, list blobs; ADLS Gen2 DFS host alias, user delegation key vending, user delegation SAS enforcement | | **Queue Storage** | `/{account}-queue/` | Create/delete queues, send/receive/peek/delete messages, visibility timeout | | **Table Storage** | `/{account}-table/` | Create/delete tables, insert/get/update/upsert/delete entities; OData `$filter` / `$select` / `$top`; server-side pagination (continuation tokens); ETag optimistic concurrency; Entity Group Transactions (`$batch`) | | **Azure Functions** | `/{account}-functions/` | Deploy & invoke HTTP-triggered functions (node, python, java, dotnet); warm-container pool | diff --git a/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/DataLakeCompatibilityTest.java b/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/DataLakeCompatibilityTest.java index 0d0f8171..87be5c8b 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/DataLakeCompatibilityTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/DataLakeCompatibilityTest.java @@ -8,9 +8,11 @@ import com.azure.storage.file.datalake.DataLakeFileSystemClient; import com.azure.storage.file.datalake.DataLakeServiceClient; import com.azure.storage.file.datalake.DataLakeServiceClientBuilder; +import com.azure.storage.file.datalake.models.DataLakeStorageException; import com.azure.storage.file.datalake.models.UserDelegationKey; import com.azure.storage.file.datalake.sas.DataLakeServiceSasSignatureValues; import com.azure.storage.file.datalake.sas.FileSystemSasPermission; +import com.azure.storage.file.datalake.sas.PathSasPermission; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -31,8 +34,9 @@ class DataLakeCompatibilityTest { private DataLakeServiceClient client; @BeforeAll - void setup() { + void setup() throws Exception { EmulatorConfig.assumeEmulatorRunning(); + EmulatorConfig.installEmulatorTlsCert(); client = new DataLakeServiceClientBuilder() .endpoint(EmulatorConfig.httpBase()) .credential(new StorageSharedKeyCredential(EmulatorConfig.ACCOUNT, EmulatorConfig.DEV_KEY)) @@ -58,7 +62,6 @@ void fileCreateUsesDfsEndpoint() { void userDelegationKeyCanGenerateUsableDfsSas() throws Exception { OffsetDateTime start = OffsetDateTime.now().minusMinutes(5); OffsetDateTime expiry = OffsetDateTime.now().plusHours(1); - EmulatorConfig.installEmulatorTlsCert(); DataLakeServiceClient bearerClient = new DataLakeServiceClientBuilder() .endpoint(EmulatorConfig.httpBase().replaceFirst("^http://", "https://")) .credential(request -> Mono.just(new AccessToken("fake-token", expiry))) @@ -71,26 +74,80 @@ void userDelegationKeyCanGenerateUsableDfsSas() throws Exception { String name = "test-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); DataLakeFileSystemClient fileSystem = bearerClient.createFileSystem(name); - FileSystemSasPermission permissions = new FileSystemSasPermission() - .setCreatePermission(true) - .setReadPermission(true) - .setWritePermission(true); - DataLakeServiceSasSignatureValues values = new DataLakeServiceSasSignatureValues(expiry, permissions) - .setStartTime(start); - String sas = fileSystem.generateUserDelegationSas(values, key, EmulatorConfig.ACCOUNT, Context.NONE); - assertTrue(sas.contains("sig=")); - - DataLakeServiceClient sasClient = new DataLakeServiceClientBuilder() - .endpoint(EmulatorConfig.httpBase()) - .sasToken(sas) + try { + FileSystemSasPermission permissions = new FileSystemSasPermission() + .setCreatePermission(true) + .setReadPermission(true) + .setWritePermission(true); + DataLakeServiceSasSignatureValues values = new DataLakeServiceSasSignatureValues(expiry, permissions) + .setStartTime(start); + String sas = fileSystem.generateUserDelegationSas(values, key, EmulatorConfig.ACCOUNT, Context.NONE); + assertTrue(sas.contains("sig=")); + + DataLakeServiceClient sasClient = new DataLakeServiceClientBuilder() + .endpoint(EmulatorConfig.httpBase()) + .sasToken(sas) + .addPolicy(dfsHostPolicy()) + .buildClient(); + DataLakeFileSystemClient sasFileSystem = sasClient.getFileSystemClient(name); + DataLakeFileClient file = sasFileSystem.createFile("dir/sas-file.txt"); + + assertTrue(file.exists()); + } finally { + client.deleteFileSystem(name); + } + } + + @Test + @DisplayName("user delegation SAS: path scope and permissions are enforced") + void userDelegationSasEnforcesPathScopeAndPermissions() { + OffsetDateTime start = OffsetDateTime.now().minusMinutes(5); + OffsetDateTime expiry = OffsetDateTime.now().plusHours(1); + DataLakeServiceClient bearerClient = new DataLakeServiceClientBuilder() + .endpoint(EmulatorConfig.httpBase().replaceFirst("^http://", "https://")) + .credential(request -> Mono.just(new AccessToken("fake-token", expiry))) .addPolicy(dfsHostPolicy()) .buildClient(); - DataLakeFileSystemClient sasFileSystem = sasClient.getFileSystemClient(name); - DataLakeFileClient file = sasFileSystem.createFile("dir/sas-file.txt"); - - assertTrue(file.exists()); + UserDelegationKey key = bearerClient.getUserDelegationKey(start, expiry); - bearerClient.deleteFileSystem(name); + String name = "test-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + DataLakeFileSystemClient fileSystem = bearerClient.createFileSystem(name); + try { + fileSystem.createFile("allowed.txt"); + fileSystem.createFile("denied.txt"); + + DataLakeServiceSasSignatureValues pathValues = new DataLakeServiceSasSignatureValues( + expiry, new PathSasPermission().setReadPermission(true)) + .setStartTime(start); + String pathSas = fileSystem.getFileClient("allowed.txt") + .generateUserDelegationSas(pathValues, key, EmulatorConfig.ACCOUNT, Context.NONE); + DataLakeServiceClient pathSasClient = new DataLakeServiceClientBuilder() + .endpoint(EmulatorConfig.httpBase()) + .sasToken(pathSas) + .addPolicy(dfsHostPolicy()) + .buildClient(); + + assertTrue(pathSasClient.getFileSystemClient(name).getFileClient("allowed.txt").exists()); + DataLakeStorageException siblingFailure = assertThrows(DataLakeStorageException.class, + () -> pathSasClient.getFileSystemClient(name).getFileClient("denied.txt").exists()); + assertEquals(403, siblingFailure.getStatusCode()); + + DataLakeServiceSasSignatureValues readOnlyValues = new DataLakeServiceSasSignatureValues( + expiry, new FileSystemSasPermission().setReadPermission(true)) + .setStartTime(start); + String readOnlySas = fileSystem.generateUserDelegationSas( + readOnlyValues, key, EmulatorConfig.ACCOUNT, Context.NONE); + DataLakeServiceClient readOnlyClient = new DataLakeServiceClientBuilder() + .endpoint(EmulatorConfig.httpBase()) + .sasToken(readOnlySas) + .addPolicy(dfsHostPolicy()) + .buildClient(); + DataLakeStorageException permissionFailure = assertThrows(DataLakeStorageException.class, + () -> readOnlyClient.getFileSystemClient(name).createFile("cannot-create.txt")); + assertEquals(403, permissionFailure.getStatusCode()); + } finally { + client.deleteFileSystem(name); + } } private static HttpPipelinePolicy dfsHostPolicy() { diff --git a/docs/services/blob.md b/docs/services/blob.md index 64c091cb..329e52b5 100644 --- a/docs/services/blob.md +++ b/docs/services/blob.md @@ -21,7 +21,10 @@ Blob XML responses, and the Data Lake Storage Gen2 DFS host alias. Blob backend so ADLS SDK path clients can create, read, write, and delete paths through the same local data store - **User delegation key vending** — `POST ?restype=service&comp=userdelegationkey` returns - deterministic Azure-shaped XML for SDK-generated user delegation SAS flows + Azure-shaped XML for SDK-generated user delegation SAS flows +- **User delegation SAS enforcement** — validates SDK-generated user delegation SAS signatures, + expiry, signed key validity, permissions, and container/blob/directory resource scope for Blob + and ADLS path operations - **Range download** — `Range: bytes=…` returns `206 Partial Content` - **Conditional download** — `If-Match` / `If-None-Match` honored; a stale ETag is rejected - **Metadata** — `x-ms-meta-*` set on upload and returned on Get, round-tripped exactly @@ -91,7 +94,11 @@ floci-az: - **Shared Key signatures are accepted but not cryptographically verified** — the emulator is a local dev target; any well-formed `Authorization` header (or the Azurite key) is honored. -- **No SAS enforcement** — SAS query parameters are parsed but not validated. +- **SAS enforcement is scoped to user delegation SAS** — SDK-generated user delegation SAS tokens + for container (`sr=c`), blob (`sr=b`), and ADLS directory (`sr=d`) resources are validated. + Account SAS, stored access policies, IP/protocol restrictions, and the full SAS feature matrix + are not fully modeled. User delegation keys are protected by a process-local secret, so SAS + tokens issued by a previous emulator process are invalid after restart. - **Snapshots, versioning, leases, and tiering are not modeled.** `Get Blob` and `Get Container Properties` still report the lease headers Azure always returns, fixed at the unleased values (`x-ms-lease-status: unlocked`, `x-ms-lease-state: available`), because strict SDK diff --git a/docs/services/index.md b/docs/services/index.md index b07c4eaa..6f6f8980 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -5,7 +5,7 @@ Floci-AZ provides emulation for several core Azure services. | Service | Endpoint | Implementation Status | |---|---|---| | **Azure Resource Manager** | `/subscriptions/...` + `/providers/...` | ✅ Subscriptions, resource groups, resource/provider listing; management-plane fallthrough for the `Microsoft.*` providers | -| **Blob Storage** | `/{account}/` | ✅ Full CRUD; ADLS Gen2 DFS host alias and user delegation key vending | +| **Blob Storage** | `/{account}/` | ✅ Full CRUD; ADLS Gen2 DFS host alias, user delegation key vending, user delegation SAS enforcement | | **Queue Storage** | `/{account}-queue/` | ✅ Full CRUD | | **Table Storage** | `/{account}-table/` | ✅ Full CRUD | | **Azure Functions** | `/{account}-functions/` | ✅ HTTP Triggers, Docker runtimes | diff --git a/src/main/java/io/floci/az/core/AuthContext.java b/src/main/java/io/floci/az/core/AuthContext.java index faf557fe..40749f49 100644 --- a/src/main/java/io/floci/az/core/AuthContext.java +++ b/src/main/java/io/floci/az/core/AuthContext.java @@ -1,7 +1,15 @@ package io.floci.az.core; +import io.floci.az.core.auth.StorageSasToken; +import java.util.Optional; + public record AuthContext( String accountName, AuthType type, - boolean isValid -) {} + boolean isValid, + Optional storageSas +) { + public AuthContext(String accountName, AuthType type, boolean isValid) { + this(accountName, type, isValid, Optional.empty()); + } +} diff --git a/src/main/java/io/floci/az/core/auth/SasTokenVerifier.java b/src/main/java/io/floci/az/core/auth/SasTokenVerifier.java index 4e01e22a..766c4ede 100644 --- a/src/main/java/io/floci/az/core/auth/SasTokenVerifier.java +++ b/src/main/java/io/floci/az/core/auth/SasTokenVerifier.java @@ -5,33 +5,29 @@ import io.floci.az.core.AzureRequest; import jakarta.enterprise.context.ApplicationScoped; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.OffsetDateTime; -import java.time.format.DateTimeParseException; import java.util.Optional; @ApplicationScoped public class SasTokenVerifier implements AuthVerifier { @Override public Optional verify(AzureRequest request) { - if (request.queryParams().containsKey("sig") && request.queryParams().containsKey("sv")) { - String signedExpiry = request.queryParams().get("se"); - if (signedExpiry != null) { - try { - String decodedExpiry = URLDecoder.decode(signedExpiry, StandardCharsets.UTF_8); - Instant expiry = OffsetDateTime.parse(decodedExpiry).toInstant(); - if (!expiry.isAfter(Instant.now())) { - return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, false)); - } - } catch (DateTimeParseException e) { - return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, false)); + return StorageSasToken.from(request.queryParams()) + .map(sas -> { + Instant now = Instant.now(); + boolean valid = true; + if (sas.startTime() != null) { + valid = sas.parsedStartTime().map(OffsetDateTime::toInstant) + .filter(start -> !start.isAfter(now)) + .isPresent(); } - } - // Accept any sig in dev mode - return Optional.of(new AuthContext(request.accountName(), AuthType.SAS, true)); - } - return Optional.empty(); + if (sas.expiryTime() != null) { + valid = valid && sas.parsedExpiryTime().map(OffsetDateTime::toInstant) + .filter(expiry -> expiry.isAfter(now)) + .isPresent(); + } + return new AuthContext(request.accountName(), AuthType.SAS, valid, Optional.of(sas)); + }); } } diff --git a/src/main/java/io/floci/az/core/auth/StorageSasAuthorization.java b/src/main/java/io/floci/az/core/auth/StorageSasAuthorization.java new file mode 100644 index 00000000..700c2691 --- /dev/null +++ b/src/main/java/io/floci/az/core/auth/StorageSasAuthorization.java @@ -0,0 +1,262 @@ +package io.floci.az.core.auth; + +import io.floci.az.core.AzureErrorResponse; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; + +@ApplicationScoped +public class StorageSasAuthorization { + + private static final String HMAC_SHA256 = "HmacSHA256"; + + private final UserDelegationKeyMaterial keyMaterial; + + @Inject + public StorageSasAuthorization(UserDelegationKeyMaterial keyMaterial) { + this.keyMaterial = keyMaterial; + } + + public Optional authorizeRead(String account, String container, String path, StorageSasToken token) { + return authorize(account, container, path, token, Operation.READ); + } + + public Optional authorizeList(String account, String container, StorageSasToken token) { + return authorize(account, container, null, token, Operation.LIST); + } + + public Optional authorizeCreate(String account, String container, String path, StorageSasToken token) { + return authorize(account, container, path, token, Operation.CREATE); + } + + public Optional authorizeWrite(String account, String container, String path, StorageSasToken token) { + return authorize(account, container, path, token, Operation.WRITE); + } + + public Optional authorizeDelete(String account, String container, String path, StorageSasToken token) { + return authorize(account, container, path, token, Operation.DELETE); + } + + private Optional authorize( + String account, + String container, + String path, + StorageSasToken token, + Operation operation + ) { + if (token.resource() == null || token.permissions() == null || token.expiryTime() == null) { + return Optional.of(authenticationFailed()); + } + if (!isSupportedResource(token.resource())) { + return Optional.of(authenticationFailed()); + } + if (!delegationKeyValid(token)) { + return Optional.of(authenticationFailed()); + } + if (!signatureMatches(account, container, path, token)) { + return Optional.of(authenticationFailed()); + } + if (!resourceCoversPath(token, path)) { + return Optional.of(authorizationPermissionMismatch()); + } + if (!operation.allowedBy(token)) { + return Optional.of(authorizationPermissionMismatch()); + } + return Optional.empty(); + } + + private static boolean delegationKeyValid(StorageSasToken token) { + if (!UserDelegationKeyMaterial.SIGNED_OBJECT_ID.equals(token.signedObjectId()) + || !UserDelegationKeyMaterial.SIGNED_TENANT_ID.equals(token.signedTenantId()) + || !"b".equals(token.signedKeyService()) + || token.signedKeyVersion() == null) { + return false; + } + + Optional keyStart = token.parsedSignedKeyStart().map(OffsetDateTime::toInstant); + Optional keyExpiry = token.parsedSignedKeyExpiry().map(OffsetDateTime::toInstant); + Optional sasExpiry = token.parsedExpiryTime().map(OffsetDateTime::toInstant); + if (keyStart.isEmpty() || keyExpiry.isEmpty() || sasExpiry.isEmpty()) { + return false; + } + + Instant now = Instant.now(); + return !keyStart.get().isAfter(now) + && keyExpiry.get().isAfter(now) + && !sasExpiry.get().isAfter(keyExpiry.get()); + } + + private boolean signatureMatches(String account, String container, String path, StorageSasToken token) { + String canonicalName = canonicalName(account, container, signedPath(token, path)); + String expected = hmac(keyMaterial.signingKeyForAccount(account), stringToSign(token, canonicalName)); + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), + token.signature().getBytes(StandardCharsets.UTF_8) + ); + } + + private static String signedPath(StorageSasToken token, String requestPath) { + if ("c".equals(token.resource())) { + return null; + } + if ("d".equals(token.resource())) { + return signedDirectoryPath(token, requestPath); + } + return requestPath; + } + + private static String canonicalName(String account, String container, String path) { + if (path == null || path.isBlank()) { + return "/blob/" + account + "/" + container; + } + return "/blob/" + account + "/" + container + "/" + normalizePath(path); + } + + private static String stringToSign(StorageSasToken token, String canonicalName) { + return String.join("\n", + value(token.permissions()), + value(token.startTime()), + value(token.expiryTime()), + canonicalName, + value(token.signedObjectId()), + value(token.signedTenantId()), + value(token.signedKeyStart()), + value(token.signedKeyExpiry()), + value(token.signedKeyService()), + value(token.signedKeyVersion()), + value(token.preauthorizedAgentObjectId()), + value(token.agentObjectId()), + value(token.correlationId()), + value(token.ipRange()), + value(token.protocol()), + value(token.version()), + value(token.resource()), + "", + value(token.encryptionScope()), + value(token.cacheControl()), + value(token.contentDisposition()), + value(token.contentEncoding()), + value(token.contentLanguage()), + value(token.contentType()) + ); + } + + private static String hmac(String base64Key, String stringToSign) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(Base64.getDecoder().decode(base64Key), HMAC_SHA256)); + return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("Unable to compute SAS signature", e); + } + } + + private static boolean resourceCoversPath(StorageSasToken token, String path) { + String normalizedPath = normalizePath(path); + return switch (token.resource()) { + case "c" -> true; + case "b" -> normalizedPath != null && !normalizedPath.isBlank(); + case "d" -> signedDirectoryPath(token, normalizedPath) != null; + default -> false; + }; + } + + private static String signedDirectoryPath(StorageSasToken token, String path) { + String normalizedPath = normalizePath(path); + if (normalizedPath == null || normalizedPath.isBlank()) { + return null; + } + if (token.directoryDepth() == null) { + return normalizedPath; + } + try { + int depth = Integer.parseInt(token.directoryDepth()); + if (depth <= 0) { + return null; + } + String[] segments = normalizedPath.split("/"); + if (segments.length < depth) { + return null; + } + return String.join("/", Arrays.copyOf(segments, depth)); + } catch (NumberFormatException e) { + return null; + } + } + + private static boolean isSupportedResource(String resource) { + return "c".equals(resource) || "b".equals(resource) || "d".equals(resource); + } + + private static String normalizePath(String path) { + if (path == null) { + return null; + } + while (path.startsWith("/")) { + path = path.substring(1); + } + return path; + } + + private static String value(String value) { + return value == null ? "" : value; + } + + private static Response authenticationFailed() { + return new AzureErrorResponse("AuthenticationFailed", + "Server failed to authenticate the request. Make sure the value of Authorization header " + + "is formed correctly including the signature.") + .toXmlResponse(Response.Status.FORBIDDEN.getStatusCode()); + } + + private static Response authorizationPermissionMismatch() { + return new AzureErrorResponse("AuthorizationPermissionMismatch", + "This request is not authorized to perform this operation using this permission.") + .toXmlResponse(Response.Status.FORBIDDEN.getStatusCode()); + } + + private enum Operation { + READ { + @Override + boolean allowedBy(StorageSasToken token) { + return token.hasPermission('r'); + } + }, + LIST { + @Override + boolean allowedBy(StorageSasToken token) { + return token.hasPermission('l'); + } + }, + CREATE { + @Override + boolean allowedBy(StorageSasToken token) { + return token.hasAnyPermission('c', 'w'); + } + }, + WRITE { + @Override + boolean allowedBy(StorageSasToken token) { + return token.hasPermission('w'); + } + }, + DELETE { + @Override + boolean allowedBy(StorageSasToken token) { + return token.hasPermission('d'); + } + }; + + abstract boolean allowedBy(StorageSasToken token); + } +} diff --git a/src/main/java/io/floci/az/core/auth/StorageSasToken.java b/src/main/java/io/floci/az/core/auth/StorageSasToken.java new file mode 100644 index 00000000..63fd1008 --- /dev/null +++ b/src/main/java/io/floci/az/core/auth/StorageSasToken.java @@ -0,0 +1,118 @@ +package io.floci.az.core.auth; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Optional; + +public record StorageSasToken( + String version, + String signature, + String permissions, + String resource, + String startTime, + String expiryTime, + String signedObjectId, + String signedTenantId, + String signedKeyStart, + String signedKeyExpiry, + String signedKeyService, + String signedKeyVersion, + String protocol, + String ipRange, + String identifier, + String preauthorizedAgentObjectId, + String agentObjectId, + String correlationId, + String directoryDepth, + String cacheControl, + String contentDisposition, + String contentEncoding, + String contentLanguage, + String contentType, + String encryptionScope +) { + + public static Optional from(Map query) { + String signature = blankToNull(query.get("sig")); + String version = blankToNull(query.get("sv")); + if (signature == null || version == null) { + return Optional.empty(); + } + return Optional.of(new StorageSasToken( + version, + signature, + blankToNull(query.get("sp")), + blankToNull(query.get("sr")), + blankToNull(query.get("st")), + blankToNull(query.get("se")), + blankToNull(query.get("skoid")), + blankToNull(query.get("sktid")), + blankToNull(query.get("skt")), + blankToNull(query.get("ske")), + blankToNull(query.get("sks")), + blankToNull(query.get("skv")), + blankToNull(query.get("spr")), + blankToNull(query.get("sip")), + blankToNull(query.get("si")), + blankToNull(query.get("saoid")), + blankToNull(query.get("suoid")), + blankToNull(query.get("scid")), + blankToNull(query.get("sdd")), + blankToNull(query.get("rscc")), + blankToNull(query.get("rscd")), + blankToNull(query.get("rsce")), + blankToNull(query.get("rscl")), + blankToNull(query.get("rsct")), + blankToNull(query.get("ses")) + )); + } + + public Optional parsedStartTime() { + return parseDate(startTime); + } + + public Optional parsedExpiryTime() { + return parseDate(expiryTime); + } + + public Optional parsedSignedKeyStart() { + return parseDate(signedKeyStart); + } + + public Optional parsedSignedKeyExpiry() { + return parseDate(signedKeyExpiry); + } + + public boolean hasPermission(char permission) { + return permissions != null && permissions.indexOf(permission) >= 0; + } + + public boolean hasAnyPermission(char... candidates) { + for (char candidate : candidates) { + if (hasPermission(candidate)) { + return true; + } + } + return false; + } + + private static Optional parseDate(String value) { + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(OffsetDateTime.parse(value)); + } catch (DateTimeParseException e) { + return Optional.empty(); + } + } + + private static String blankToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/src/main/java/io/floci/az/core/auth/UserDelegationKeyMaterial.java b/src/main/java/io/floci/az/core/auth/UserDelegationKeyMaterial.java new file mode 100644 index 00000000..68e27aef --- /dev/null +++ b/src/main/java/io/floci/az/core/auth/UserDelegationKeyMaterial.java @@ -0,0 +1,52 @@ +package io.floci.az.core.auth; + +import jakarta.enterprise.context.ApplicationScoped; + +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; + +/** + * Derives account-scoped delegation keys from a process-local random master key. + * + *

No per-account key state is retained, which keeps account churn bounded. Restarting the + * emulator rotates the master key and invalidates previously signed user delegation SAS tokens. + */ +@ApplicationScoped +public class UserDelegationKeyMaterial { + + public static final String SIGNED_OBJECT_ID = "00000000-0000-0000-0000-000000000000"; + public static final String SIGNED_TENANT_ID = "00000000-0000-0000-0000-000000000000"; + + private static final String HMAC_SHA256 = "HmacSHA256"; + + private final SecretKey masterKey; + + public UserDelegationKeyMaterial() { + this.masterKey = generateMasterKey(); + } + + public String signingKeyForAccount(String accountName) { + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(masterKey); + byte[] accountKey = mac.doFinal(accountName.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(accountKey); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to derive user delegation key material", e); + } + } + + private static SecretKey generateMasterKey() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(HMAC_SHA256); + keyGenerator.init(256); + return keyGenerator.generateKey(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to generate user delegation master key", e); + } + } +} diff --git a/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java b/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java index 30c1b126..e24d5427 100644 --- a/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java +++ b/src/main/java/io/floci/az/services/blob/BlobServiceHandler.java @@ -8,6 +8,8 @@ import io.floci.az.core.ServiceRoutes; import io.floci.az.core.Resettable; import io.floci.az.core.StoredObject; +import io.floci.az.core.auth.StorageSasAuthorization; +import io.floci.az.core.auth.StorageSasToken; import io.floci.az.core.XmlBuilder; import io.floci.az.core.XmlUtils; import io.floci.az.core.storage.StorageBackend; @@ -62,12 +64,15 @@ public class BlobServiceHandler implements AzureServiceHandler, Resettable { private final EmulatorConfig config; private final UserDelegationKeyService userDelegationKeyService; + private final StorageSasAuthorization sasAuthorization; @Inject public BlobServiceHandler(StorageFactory storageFactory, EmulatorConfig config, - UserDelegationKeyService userDelegationKeyService) { + UserDelegationKeyService userDelegationKeyService, + StorageSasAuthorization sasAuthorization) { this.config = config; this.userDelegationKeyService = userDelegationKeyService; + this.sasAuthorization = sasAuthorization; this.store = storageFactory.create("blob"); } @@ -243,6 +248,10 @@ private Response getBlobServiceProperties() { } private Response getContainer(AzureRequest request, String containerName, boolean headOnly) { + Response authFailure = authorizeRead(request, containerName, null); + if (authFailure != null) { + return authFailure; + } if (store.get(nsKey(request.accountName(), containerName)).isEmpty()) { return new AzureErrorResponse("ContainerNotFound", "The specified container does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); @@ -260,6 +269,10 @@ private Response getContainer(AzureRequest request, String containerName, boolea } private Response createContainer(AzureRequest request, String containerName) { + Response authFailure = authorizeCreate(request, containerName, null); + if (authFailure != null) { + return authFailure; + } String key = nsKey(request.accountName(), containerName); if (store.get(key).isPresent()) { return new AzureErrorResponse("ContainerAlreadyExists", "The specified container already exists.") @@ -273,6 +286,10 @@ private Response createContainer(AzureRequest request, String containerName) { } private Response deleteContainer(AzureRequest request, String containerName) { + Response authFailure = authorizeDelete(request, containerName, null); + if (authFailure != null) { + return authFailure; + } store.delete(nsKey(request.accountName(), containerName)); String objPrefix = request.accountName() + "/" + containerName + "/"; String blkPrefix = BLK_PREFIX + objPrefix; @@ -284,6 +301,10 @@ private Response deleteContainer(AzureRequest request, String containerName) { } private Response listContainers(AzureRequest request) { + Response authFailure = authorizeList(request, null); + if (authFailure != null) { + return authFailure; + } String prefix = request.queryParams().getOrDefault("prefix", ""); String nsFilter = NS_PREFIX + request.accountName() + "/" + prefix; @@ -306,12 +327,18 @@ private Response listContainers(AzureRequest request) { private Response putBlob(AzureRequest request, String containerName, String blobName) { try { + Optional existing = store.get(objKey(request.accountName(), containerName, blobName)); + Response authFailure = existing.isPresent() + ? authorizeWrite(request, containerName, blobName) + : authorizeCreate(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } if (store.get(nsKey(request.accountName(), containerName)).isEmpty()) { return new AzureErrorResponse("ContainerNotFound", "The specified container does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); } - Optional existing = store.get(objKey(request.accountName(), containerName, blobName)); Response conditionFailure = validateBlobConditions(request, existing); if (conditionFailure != null) { return conditionFailure; @@ -342,6 +369,10 @@ private Response putBlob(AzureRequest request, String containerName, String blob } private Response getBlob(AzureRequest request, String containerName, String blobName, boolean headOnly) { + Response authFailure = authorizeRead(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } Optional object = store.get(objKey(request.accountName(), containerName, blobName)); if (object.isEmpty()) { @@ -424,6 +455,10 @@ private Response getBlob(AzureRequest request, String containerName, String blob } private Response deleteBlob(AzureRequest request, String containerName, String blobName) { + Response authFailure = authorizeDelete(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } Optional object = store.get(objKey(request.accountName(), containerName, blobName)); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") @@ -438,6 +473,10 @@ private Response deleteBlob(AzureRequest request, String containerName, String b } private Response getBlobMetadata(AzureRequest request, String containerName, String blobName) { + Response authFailure = authorizeRead(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } Optional object = store.get(objKey(request.accountName(), containerName, blobName)); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") @@ -458,6 +497,10 @@ private Response getBlobMetadata(AzureRequest request, String containerName, Str } private Response setBlobMetadata(AzureRequest request, String containerName, String blobName) { + Response authFailure = authorizeWrite(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } Optional object = store.get(objKey(request.accountName(), containerName, blobName)); if (object.isEmpty()) { return new AzureErrorResponse("BlobNotFound", "The specified blob does not exist.") @@ -489,6 +532,10 @@ private Response setBlobMetadata(AzureRequest request, String containerName, Str } private Response listBlobs(AzureRequest request, String containerName) { + Response authFailure = authorizeList(request, containerName); + if (authFailure != null) { + return authFailure; + } String prefix = request.queryParams().getOrDefault("prefix", ""); String delimiter = request.queryParams().getOrDefault("delimiter", ""); String marker = request.queryParams().getOrDefault("marker", ""); @@ -553,6 +600,10 @@ private static int parseMaxResults(String value) { */ private Response putBlock(AzureRequest request, String containerName, String blobName) { try { + Response authFailure = authorizeWrite(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } if (store.get(nsKey(request.accountName(), containerName)).isEmpty()) { return new AzureErrorResponse("ContainerNotFound", "The specified container does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); @@ -584,6 +635,10 @@ private Response putBlock(AzureRequest request, String containerName, String blo */ private Response putBlockList(AzureRequest request, String containerName, String blobName) { try { + Response authFailure = authorizeWrite(request, containerName, blobName); + if (authFailure != null) { + return authFailure; + } if (store.get(nsKey(request.accountName(), containerName)).isEmpty()) { return new AzureErrorResponse("ContainerNotFound", "The specified container does not exist.") .toXmlResponse(Response.Status.NOT_FOUND.getStatusCode()); @@ -672,6 +727,10 @@ private static void addBlobHttpProperties(AzureRequest request, Map committed = new ArrayList<>(); @@ -747,6 +806,47 @@ private static String blockStagingPrefix(String account, String container, Strin return BLK_PREFIX + objKey(account, container, blobName) + ":"; } + private Response authorizeRead(AzureRequest request, String containerName, String blobName) { + return storageSas(request) + .flatMap(token -> sasAuthorization.authorizeRead( + request.accountName(), containerName, blobName, token)) + .orElse(null); + } + + private Response authorizeList(AzureRequest request, String containerName) { + return storageSas(request) + .flatMap(token -> sasAuthorization.authorizeList(request.accountName(), containerName, token)) + .orElse(null); + } + + private Response authorizeCreate(AzureRequest request, String containerName, String blobName) { + return storageSas(request) + .flatMap(token -> sasAuthorization.authorizeCreate( + request.accountName(), containerName, blobName, token)) + .orElse(null); + } + + private Response authorizeWrite(AzureRequest request, String containerName, String blobName) { + return storageSas(request) + .flatMap(token -> sasAuthorization.authorizeWrite( + request.accountName(), containerName, blobName, token)) + .orElse(null); + } + + private Response authorizeDelete(AzureRequest request, String containerName, String blobName) { + return storageSas(request) + .flatMap(token -> sasAuthorization.authorizeDelete( + request.accountName(), containerName, blobName, token)) + .orElse(null); + } + + private static Optional storageSas(AzureRequest request) { + if (request.authContext() == null || request.authContext().type() != AuthType.SAS) { + return Optional.empty(); + } + return request.authContext().storageSas(); + } + /** * Parses the block IDs from a PutBlockList XML body. * Matches {@code }, {@code }, and {@code } elements diff --git a/src/main/java/io/floci/az/services/blob/UserDelegationKeyService.java b/src/main/java/io/floci/az/services/blob/UserDelegationKeyService.java index f66ce021..77df2d8d 100644 --- a/src/main/java/io/floci/az/services/blob/UserDelegationKeyService.java +++ b/src/main/java/io/floci/az/services/blob/UserDelegationKeyService.java @@ -4,14 +4,14 @@ import io.floci.az.core.AzureRequest; import io.floci.az.core.XmlBuilder; import io.floci.az.core.XmlParser; +import io.floci.az.core.auth.UserDelegationKeyMaterial; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; @@ -19,7 +19,6 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; -import java.util.Base64; @ApplicationScoped public class UserDelegationKeyService { @@ -27,9 +26,13 @@ public class UserDelegationKeyService { private static final Logger LOG = Logger.getLogger(UserDelegationKeyService.class); private static final Duration MAX_KEY_DURATION = Duration.ofDays(7); private static final String DEFAULT_SIGNED_VERSION = "2024-11-04"; - private static final String SIGNED_OBJECT_ID = "00000000-0000-0000-0000-000000000000"; - private static final String SIGNED_TENANT_ID = "00000000-0000-0000-0000-000000000000"; - private static final String SIGNING_KEY_PREFIX = "floci-az-user-delegation:"; + + private final UserDelegationKeyMaterial keyMaterial; + + @Inject + public UserDelegationKeyService(UserDelegationKeyMaterial keyMaterial) { + this.keyMaterial = keyMaterial; + } public Response create(AzureRequest request) { String body; @@ -72,28 +75,18 @@ public Response create(AzureRequest request) { String xml = new XmlBuilder() .start("UserDelegationKey") - .elem("SignedOid", SIGNED_OBJECT_ID) - .elem("SignedTid", SIGNED_TENANT_ID) + .elem("SignedOid", UserDelegationKeyMaterial.SIGNED_OBJECT_ID) + .elem("SignedTid", UserDelegationKeyMaterial.SIGNED_TENANT_ID) .elem("SignedStart", format(start)) .elem("SignedExpiry", format(expiry)) .elem("SignedService", "b") .elem("SignedVersion", signedVersion) - .elem("Value", signingKeyForAccount(request.accountName())) + .elem("Value", keyMaterial.signingKeyForAccount(request.accountName())) .end("UserDelegationKey") .build(); return Response.ok(xml, "application/xml").build(); } - public static String signingKeyForAccount(String accountName) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest((SIGNING_KEY_PREFIX + accountName).getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hash); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 digest is unavailable", e); - } - } - private static OffsetDateTime parseTimestamp(String value) { try { return OffsetDateTime.parse(value); diff --git a/src/test/java/io/floci/az/core/auth/StorageSasTokenTest.java b/src/test/java/io/floci/az/core/auth/StorageSasTokenTest.java new file mode 100644 index 00000000..5db30be6 --- /dev/null +++ b/src/test/java/io/floci/az/core/auth/StorageSasTokenTest.java @@ -0,0 +1,63 @@ +package io.floci.az.core.auth; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StorageSasTokenTest { + + @Test + void parsesUserDelegationFields() { + StorageSasToken token = StorageSasToken.from(Map.ofEntries( + Map.entry("sv", "2024-11-04"), + Map.entry("sig", "abc+/="), + Map.entry("sp", "racwdl"), + Map.entry("sr", "b"), + Map.entry("st", "2026-07-15T10:00:00Z"), + Map.entry("se", "2026-07-15T11:00:00Z"), + Map.entry("skoid", UserDelegationKeyMaterial.SIGNED_OBJECT_ID), + Map.entry("sktid", UserDelegationKeyMaterial.SIGNED_TENANT_ID), + Map.entry("skt", "2026-07-15T10:00:00Z"), + Map.entry("ske", "2026-07-15T11:00:00Z"), + Map.entry("sks", "b"), + Map.entry("skv", "2024-11-04"), + Map.entry("sdd", "2"), + Map.entry("ses", "scope") + )).orElseThrow(); + + assertThat(token.version(), equalTo("2024-11-04")); + assertThat(token.signature(), equalTo("abc+/=")); + assertThat(token.permissions(), equalTo("racwdl")); + assertThat(token.resource(), equalTo("b")); + assertThat(token.signedObjectId(), equalTo(UserDelegationKeyMaterial.SIGNED_OBJECT_ID)); + assertThat(token.directoryDepth(), equalTo("2")); + assertThat(token.encryptionScope(), equalTo("scope")); + assertTrue(token.parsedStartTime().isPresent()); + assertTrue(token.parsedExpiryTime().isPresent()); + assertTrue(token.parsedSignedKeyStart().isPresent()); + assertTrue(token.parsedSignedKeyExpiry().isPresent()); + } + + @Test + void ignoresQueriesWithoutSasMarkerFields() { + assertTrue(StorageSasToken.from(Map.of("sv", "2024-11-04")).isEmpty()); + assertTrue(StorageSasToken.from(Map.of("sig", "abc")).isEmpty()); + } + + @Test + void exposesMalformedDatesAsEmptyParsedValues() { + StorageSasToken token = StorageSasToken.from(Map.of( + "sv", "2024-11-04", + "sig", "abc", + "st", "not-a-date", + "se", "also-not-a-date" + )).orElseThrow(); + + assertTrue(token.parsedStartTime().isEmpty()); + assertTrue(token.parsedExpiryTime().isEmpty()); + } +} diff --git a/src/test/java/io/floci/az/core/auth/UserDelegationKeyMaterialTest.java b/src/test/java/io/floci/az/core/auth/UserDelegationKeyMaterialTest.java new file mode 100644 index 00000000..268264ab --- /dev/null +++ b/src/test/java/io/floci/az/core/auth/UserDelegationKeyMaterialTest.java @@ -0,0 +1,44 @@ +package io.floci.az.core.auth; + +import org.junit.jupiter.api.Test; + +import java.util.Base64; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +class UserDelegationKeyMaterialTest { + + @Test + void accountKeyIsStableWithinOneEmulatorProcess() { + UserDelegationKeyMaterial material = new UserDelegationKeyMaterial(); + + assertThat(material.signingKeyForAccount("account"), + equalTo(material.signingKeyForAccount("account"))); + } + + @Test + void accountKeysAreIsolated() { + UserDelegationKeyMaterial material = new UserDelegationKeyMaterial(); + + assertThat(material.signingKeyForAccount("account-a"), + not(equalTo(material.signingKeyForAccount("account-b")))); + } + + @Test + void accountNameDoesNotDetermineKeyAcrossEmulatorProcesses() { + UserDelegationKeyMaterial firstProcess = new UserDelegationKeyMaterial(); + UserDelegationKeyMaterial secondProcess = new UserDelegationKeyMaterial(); + + assertThat(firstProcess.signingKeyForAccount("account"), + not(equalTo(secondProcess.signingKeyForAccount("account")))); + } + + @Test + void derivedSigningKeyContains256Bits() { + UserDelegationKeyMaterial material = new UserDelegationKeyMaterial(); + + assertThat(Base64.getDecoder().decode(material.signingKeyForAccount("account")).length, equalTo(32)); + } +} diff --git a/src/test/java/io/floci/az/services/BlobServiceTest.java b/src/test/java/io/floci/az/services/BlobServiceTest.java index b98e9114..21fe18a4 100644 --- a/src/test/java/io/floci/az/services/BlobServiceTest.java +++ b/src/test/java/io/floci/az/services/BlobServiceTest.java @@ -1,15 +1,21 @@ package io.floci.az.services; import io.floci.az.core.XmlParser; +import io.floci.az.core.auth.UserDelegationKeyMaterial; import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Base64; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import static io.restassured.RestAssured.given; import static org.hamcrest.MatcherAssert.assertThat; @@ -24,6 +30,9 @@ public class BlobServiceTest { private static final String BLOB_CONTENT = "Hello, Blob!"; private static final Pattern ISO_UTC_SECONDS = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"); + @Inject + UserDelegationKeyMaterial keyMaterial; + @BeforeEach void reset() { given().post("/_admin/reset").then().statusCode(204); @@ -298,6 +307,295 @@ void expiredSasReturnsAuthenticationFailed() { .body(containsString("AuthenticationFailed")); } + @Test + void validReadSasCanReadBlob() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + given() + .when().get("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + sas("r", "b", CONTAINER, BLOB)) + .then() + .statusCode(200) + .body(equalTo(BLOB_CONTENT)); + } + + @Test + void arbitraryNonExpiredSasReturnsAuthenticationFailed() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + String se = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).withNano(0).toString(); + given() + .when().get("/{account}/{container}/{blob}?se={se}&sp=r&sv=2024-11-04&sr=b&sig=ignored", + ACCOUNT, CONTAINER, BLOB, se) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthenticationFailed"); + } + + @Test + void accountNameCannotBeUsedToForgeUserDelegationSas() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + String forgedSas = sasSignedWith( + legacyPublicSigningKey(ACCOUNT), "r", "b", CONTAINER, BLOB); + + given() + .when().get("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, forgedSas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthenticationFailed"); + } + + @Test + void sasWithExpiredUserDelegationKeyReturnsAuthenticationFailed() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + OffsetDateTime keyStart = OffsetDateTime.now(ZoneOffset.UTC).minusHours(2).withNano(0); + OffsetDateTime keyExpiry = OffsetDateTime.now(ZoneOffset.UTC).minusHours(1).withNano(0); + + given() + .when().get("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + sas("r", "b", CONTAINER, BLOB, keyStart, keyExpiry)) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthenticationFailed"); + } + + @Test + void readOnlySasCannotWrite() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .when().put("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + sas("r", "b", CONTAINER, BLOB)) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + } + + @Test + void appendOnlySasCannotCreateOrOverwriteBlob() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + String appendOnlySas = sas("a", "b", CONTAINER, BLOB); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("new") + .when().put("/{account}/{container}/{blob}?{sas}", + ACCOUNT, CONTAINER, BLOB, appendOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("original") + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("overwritten") + .when().put("/{account}/{container}/{blob}?{sas}", + ACCOUNT, CONTAINER, BLOB, appendOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .when().get("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB) + .then() + .statusCode(200) + .body(equalTo("original")); + } + + @Test + void appendOnlySasCannotMutateMetadataOrBlockList() { + putTestBlob(BLOB_CONTENT); + String appendOnlySas = sas("a", "b", CONTAINER, BLOB); + String blockId = Base64.getEncoder().encodeToString("block-1".getBytes(StandardCharsets.UTF_8)); + + given() + .header("x-ms-meta-owner", "attacker") + .when().put("/{account}/{container}/{blob}?comp=metadata&{sas}", + ACCOUNT, CONTAINER, BLOB, appendOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .body("chunk") + .when().put("/{account}/{container}/{blob}?comp=block&blockid={id}&{sas}", + ACCOUNT, CONTAINER, BLOB, blockId, appendOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .body("" + blockId + "") + .when().put("/{account}/{container}/{blob}?comp=blocklist&{sas}", + ACCOUNT, CONTAINER, BLOB, appendOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + } + + @Test + void createOnlySasCanCreateButCannotOverwriteBlob() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + String createOnlySas = sas("c", "b", CONTAINER, BLOB); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("created") + .when().put("/{account}/{container}/{blob}?{sas}", + ACCOUNT, CONTAINER, BLOB, createOnlySas) + .then() + .statusCode(201); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("overwritten") + .when().put("/{account}/{container}/{blob}?{sas}", + ACCOUNT, CONTAINER, BLOB, createOnlySas) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .when().get("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB) + .then() + .statusCode(200) + .body(equalTo("created")); + } + + @Test + void writeSasCanCreateAndOverwriteBlob() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + String writeSas = sas("w", "b", CONTAINER, BLOB); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("created") + .when().put("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, writeSas) + .then() + .statusCode(201); + + given() + .header("x-ms-blob-type", "BlockBlob") + .body("overwritten") + .when().put("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, writeSas) + .then() + .statusCode(201); + + given() + .when().get("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB) + .then() + .statusCode(200) + .body(equalTo("overwritten")); + } + + @Test + void pathScopedSasCannotAccessSiblingBlob() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body("allowed") + .put("/{account}/{container}/allowed.txt", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body("denied") + .put("/{account}/{container}/denied.txt", ACCOUNT, CONTAINER); + + given() + .when().get("/{account}/{container}/denied.txt?{sas}", ACCOUNT, CONTAINER, + sas("r", "b", CONTAINER, "allowed.txt")) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthenticationFailed"); + } + + @Test + void filesystemScopedSasCanAccessMultipleBlobsInContainer() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + for (String name : new String[] {"one.txt", "two.txt"}) { + given() + .header("x-ms-blob-type", "BlockBlob") + .body(name) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, name); + } + String sas = sas("rl", "c", CONTAINER, null); + + given() + .when().get("/{account}/{container}/one.txt?{sas}", ACCOUNT, CONTAINER, sas) + .then() + .statusCode(200) + .body(equalTo("one.txt")); + + given() + .when().get("/{account}/{container}/two.txt?{sas}", ACCOUNT, CONTAINER, sas) + .then() + .statusCode(200) + .body(equalTo("two.txt")); + } + + @Test + void containerListRequiresListPermission() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + + given() + .when().get("/{account}/{container}?restype=container&comp=list&{sas}", ACCOUNT, CONTAINER, + sas("r", "c", CONTAINER, null)) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .when().get("/{account}/{container}?restype=container&comp=list&{sas}", ACCOUNT, CONTAINER, + sas("l", "c", CONTAINER, null)) + .then() + .statusCode(200); + } + + @Test + void deleteRequiresDeletePermission() { + given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); + given() + .header("x-ms-blob-type", "BlockBlob") + .body(BLOB_CONTENT) + .put("/{account}/{container}/{blob}", ACCOUNT, CONTAINER, BLOB); + + given() + .when().delete("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + sas("r", "b", CONTAINER, BLOB)) + .then() + .statusCode(403) + .header("x-ms-error-code", "AuthorizationPermissionMismatch"); + + given() + .when().delete("/{account}/{container}/{blob}?{sas}", ACCOUNT, CONTAINER, BLOB, + sas("d", "b", CONTAINER, BLOB)) + .then() + .statusCode(202); + } + @Test void setAndGetBlobMetadata() { given().put("/{account}/{container}?restype=container", ACCOUNT, CONTAINER); @@ -748,4 +1046,115 @@ private static String nextMarker(String response) { assertThat(matcher.find(), is(true)); return matcher.group(1); } + + private String sas(String permissions, String resource, String container, String blobName) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0); + OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).withNano(0); + return sas(permissions, resource, container, blobName, start, expiry); + } + + private String sas(String permissions, String resource, String container, String blobName, + OffsetDateTime signedKeyStart, OffsetDateTime signedKeyExpiry) { + return sasSignedWith(keyMaterial.signingKeyForAccount(ACCOUNT), + permissions, resource, container, blobName, signedKeyStart, signedKeyExpiry); + } + + private static String sasSignedWith( + String base64Key, + String permissions, + String resource, + String container, + String blobName + ) { + OffsetDateTime keyStart = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0); + OffsetDateTime keyExpiry = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).withNano(0); + return sasSignedWith(base64Key, permissions, resource, container, blobName, keyStart, keyExpiry); + } + + private static String sasSignedWith( + String base64Key, + String permissions, + String resource, + String container, + String blobName, + OffsetDateTime signedKeyStart, + OffsetDateTime signedKeyExpiry + ) { + OffsetDateTime start = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0); + OffsetDateTime expiry = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).withNano(0); + String st = start.toString(); + String se = expiry.toString(); + String skt = signedKeyStart.toString(); + String ske = signedKeyExpiry.toString(); + String version = "2024-11-04"; + String canonicalName = canonicalName(container, "c".equals(resource) ? null : blobName); + String stringToSign = String.join("\n", + permissions, + st, + se, + canonicalName, + UserDelegationKeyMaterial.SIGNED_OBJECT_ID, + UserDelegationKeyMaterial.SIGNED_TENANT_ID, + skt, + ske, + "b", + version, + "", + "", + "", + "", + "", + version, + resource, + "", + "", + "", + "", + "", + "", + "" + ); + String signature = hmac(base64Key, stringToSign); + return "sv=" + version + + "&st=" + st + + "&se=" + se + + "&skoid=" + UserDelegationKeyMaterial.SIGNED_OBJECT_ID + + "&sktid=" + UserDelegationKeyMaterial.SIGNED_TENANT_ID + + "&skt=" + skt + + "&ske=" + ske + + "&sks=b" + + "&skv=" + version + + "&sr=" + resource + + "&sp=" + permissions + + "&sig=" + signature; + } + + private static String legacyPublicSigningKey(String accountName) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] key = digest.digest( + ("floci-az-user-delegation:" + accountName).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(key); + } catch (Exception e) { + throw new IllegalStateException("Unable to derive legacy test key", e); + } + } + + private static String canonicalName(String container, String blobName) { + if (blobName == null || blobName.isBlank()) { + return "/blob/" + ACCOUNT + "/" + container; + } + return "/blob/" + ACCOUNT + "/" + container + "/" + blobName; + } + + private static String hmac(String base64Key, String stringToSign) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(Base64.getDecoder().decode(base64Key), "HmacSHA256")); + return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("Unable to sign test SAS", e); + } + } + }