The problem was in the Ceph version vs AWS SDK Version. The Ceph version uses MD5 to generate the checksums, while the AWS SDK uses CRC32 resulting in a checksum mismatch.
The AWS SDK lets you work around this by setting the requestChecksumCalculation field to RequestChecksumCalculation.WHEN_REQUIRED, like so:
return S3Client.builder()
.endpointOverride(URI.create(s3Properties.baseUrl()))
.region(Region.of(Region.EU_CENTRAL_1.id())) // Not used by Ceph but required by AWS SDK
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(s3Properties.accessKey(), s3Properties.secretKey())))
.forcePathStyle(true)
.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
.build();