Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions src/http/plugins/signature-v4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import Fastify from 'fastify'
import { vi } from 'vitest'
import { createRawSignatureV4Signer } from '../../test/utils/signature-v4'

const { configState, getJwtSecretMock, verifyJwtMock } = vi.hoisted(() => ({
const { configState, getJwtSecretMock, verifyJwtMock, getTenantConfigMock } = vi.hoisted(() => ({
configState: {
requestAllowXForwardedPrefix: true,
isMultitenant: false,
},
getJwtSecretMock: vi.fn(),
verifyJwtMock: vi.fn(),
getTenantConfigMock: vi.fn(),
}))

vi.mock('../../config', () => ({
getConfig: () => ({
anonKeyAsync: Promise.resolve('anon-key'),
isMultitenant: false,
isMultitenant: configState.isMultitenant,
jwtCachingEnabled: false,
requestAllowXForwardedPrefix: configState.requestAllowXForwardedPrefix,
s3ProtocolAccessKeyId: 'access-key',
Expand All @@ -39,7 +41,7 @@ vi.mock('@internal/auth', () => ({

vi.mock('@internal/database', () => ({
getJwtSecret: getJwtSecretMock,
getTenantConfig: vi.fn(),
getTenantConfig: getTenantConfigMock,
s3CredentialsManager: {
getS3CredentialsByAccessKey: vi.fn(),
},
Expand All @@ -57,8 +59,9 @@ const signRawPath = createRawSignatureV4Signer(credentials, {
method: 'GET',
})

async function buildApp(requestAllowXForwardedPrefix: boolean) {
async function buildApp(requestAllowXForwardedPrefix: boolean, isMultitenant = false) {
configState.requestAllowXForwardedPrefix = requestAllowXForwardedPrefix
configState.isMultitenant = isMultitenant
vi.resetModules()
getJwtSecretMock.mockResolvedValue({
secret: 'jwt-secret',
Expand Down Expand Up @@ -170,3 +173,71 @@ describe('SignatureV4 plugin forwarded prefix', () => {
}
})
})

describe('SignatureV4 plugin session token secrets', () => {
const internalPath = '/bucket/object'
const sessionToken = 'session-jwt'

function signWithSessionToken(secretAccessKey: string) {
return createRawSignatureV4Signer(
{
...credentials,
accessKeyId: 'tenant-id',
secretAccessKey,
sessionToken,
},
{ method: 'GET' }
)(`${forwardedPrefix}${internalPath}`)
}

it.each([
{ name: 'tenantId', secret: 'tenant-id' },
{ name: 'anon key', secret: 'anon-key' },
])('verifies a session token request signed with the $name as secret', async ({ secret }) => {
const signedRequest = await signWithSessionToken(secret)
const { app, wasHandled } = await buildApp(true)

try {
const response = await sendRequest(app, signedRequest, internalPath)

expect(response.statusCode).toBe(204)
expect(wasHandled()).toBe(true)
expect(verifyJwtMock).toHaveBeenCalledWith(sessionToken, 'jwt-secret', null)
} finally {
await app.close()
}
})

it('rejects a session token request signed with an unknown secret', async () => {
const signedRequest = await signWithSessionToken('unknown-secret')
const { app, wasHandled } = await buildApp(true)

try {
const response = await sendRequest(app, signedRequest, internalPath)

expect(response.statusCode).toBe(403)
expect(JSON.parse(response.body)).toEqual({
code: 'SignatureDoesNotMatch',
})
expect(wasHandled()).toBe(false)
} finally {
await app.close()
}
})

it('verifies a tenantId-signed request for a tenant without an anon key', async () => {
getTenantConfigMock.mockResolvedValue({ anonKey: undefined })
const signedRequest = await signWithSessionToken('tenant-id')
const { app, wasHandled } = await buildApp(true, true)

try {
const response = await sendRequest(app, signedRequest, internalPath)

expect(response.statusCode).toBe(204)
expect(wasHandled()).toBe(true)
expect(getTenantConfigMock).toHaveBeenCalledWith('tenant-id')
} finally {
await app.close()
}
})
})
10 changes: 7 additions & 3 deletions src/http/plugins/signature-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,12 @@ async function createServerSignature(
? (await getTenantConfig(tenantId)).anonKey
: await anonKeyAsync

if (!tenantAnonKey) {
throw ERRORS.AccessDenied('Missing tenant anon key')
// The session token is the real credential (verified as a JWT after the
// signature check). The signing secret is the tenantId itself, or the
// anon key for backwards compatibility.
const secretKeys = [tenantId]
if (tenantAnonKey) {
secretKeys.push(tenantAnonKey)
}

const signature = new SignatureV4({
Expand All @@ -270,7 +274,7 @@ async function createServerSignature(
publicUrl: parsedPublicUrl,
credentials: {
accessKey: tenantId,
secretKey: tenantAnonKey,
secretKey: secretKeys,
region: awsRegion,
service: awsService,
},
Expand Down
147 changes: 146 additions & 1 deletion src/storage/protocols/s3/signature-v4.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { createHash, createHmac } from 'node:crypto'
import { createServer, request as httpRequest, type IncomingHttpHeaders } from 'node:http'
import { type AddressInfo } from 'node:net'
import { HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { SignatureV4, SignatureV4Service } from '@storage/protocols/s3/signature-v4'
import {
EMPTY_SHA256_HASH,
SignatureV4,
SignatureV4Service,
} from '@storage/protocols/s3/signature-v4'
import { createRawSignatureV4Signer } from '../../../test/utils/signature-v4'

const credentials = {
Expand Down Expand Up @@ -319,3 +324,143 @@ describe('SignatureV4 verification', () => {
).resolves.toBe(true)
})
})

describe('SignatureV4 multiple secret candidates', () => {
const fallbackSecret = 'fallback-secret-key'

function multiSecretVerifier() {
return new SignatureV4({
enforceRegion: false,
credentials: {
accessKey: credentials.accessKeyId,
secretKey: [credentials.secretAccessKey, fallbackSecret],
region: credentials.region,
service: credentials.service,
},
})
}

function deriveSigningKey(secret: string, shortDate: string, region: string, service: string) {
const kDate = createHmac('sha256', `AWS4${secret}`).update(shortDate).digest()
const kRegion = createHmac('sha256', kDate).update(region).digest()
const kService = createHmac('sha256', kRegion).update(service).digest()
return createHmac('sha256', kService).update('aws4_request').digest()
}

const signWithFallbackSecret = createRawSignatureV4Signer({
...credentials,
secretAccessKey: fallbackSecret,
})

it.each([
{ name: 'primary', sign: signRawPath },
{ name: 'fallback', sign: signWithFallbackSecret },
])('verifies a request signed with the $name secret', async ({ sign }) => {
const signedRequest = await sign('/bucket/object')
const clientSignature = SignatureV4.parseAuthorizationHeader(signedRequest.headers)

await expect(
multiSecretVerifier().verify(clientSignature, {
url: '/bucket/object',
headers: signedRequest.headers,
method: signedRequest.method,
query: {},
})
).resolves.toBe(true)
})

it('rejects a request signed with an unknown secret', async () => {
const signWithUnknownSecret = createRawSignatureV4Signer({
...credentials,
secretAccessKey: 'unknown-secret-key',
})
const signedRequest = await signWithUnknownSecret('/bucket/object')
const clientSignature = SignatureV4.parseAuthorizationHeader(signedRequest.headers)

await expect(
multiSecretVerifier().verify(clientSignature, {
url: '/bucket/object',
headers: signedRequest.headers,
method: signedRequest.method,
query: {},
})
).resolves.toBe(false)
})

it('validates chunk signatures with the secret that matched the seed signature', async () => {
const signedRequest = await signWithFallbackSecret('/bucket/object')
const clientSignature = SignatureV4.parseAuthorizationHeader(signedRequest.headers)
const chunkVerifier = multiSecretVerifier()

await expect(
chunkVerifier.verify(clientSignature, {
url: '/bucket/object',
headers: signedRequest.headers,
method: signedRequest.method,
query: {},
})
).resolves.toBe(true)

const { shortDate, region, service } = clientSignature.credentials
const chunkHash = createHash('sha256').update('chunk-data').digest('hex')
const stringToSign = [
'AWS4-HMAC-SHA256-PAYLOAD',
clientSignature.longDate,
`${shortDate}/${region}/${service}/aws4_request`,
clientSignature.signature,
EMPTY_SHA256_HASH,
chunkHash,
].join('\n')

const chunkSignature = (secret: string) =>
createHmac('sha256', deriveSigningKey(secret, shortDate, region, service))
.update(stringToSign)
.digest('hex')

expect(
chunkVerifier.validateChunkSignature(
clientSignature,
chunkHash,
chunkSignature(fallbackSecret)
)
).toBe(true)
expect(
chunkVerifier.validateChunkSignature(
clientSignature,
chunkHash,
chunkSignature(credentials.secretAccessKey)
)
).toBe(false)
})

it('verifies a POST policy signed with the fallback secret', () => {
const policy = Buffer.from(
JSON.stringify({ expiration: '2030-01-01T00:00:00Z', conditions: [] })
).toString('base64')
const shortDate = '20260818'

const signature = createHmac(
'sha256',
deriveSigningKey(fallbackSecret, shortDate, credentials.region, credentials.service)
)
.update(policy)
.digest('hex')

expect(
multiSecretVerifier().verifyPostPolicySignature(
{
credentials: {
accessKey: credentials.accessKeyId,
shortDate,
region: credentials.region,
service: credentials.service,
},
signature,
signedHeaders: [],
longDate: `${shortDate}T000000Z`,
},
policy
)
).toBe(true)
})
})
Loading