diff --git a/backend/src/utils/hashHotlinkAccessToken.spec.ts b/backend/src/utils/hashHotlinkAccessToken.spec.ts index 2b57b46..1259032 100644 --- a/backend/src/utils/hashHotlinkAccessToken.spec.ts +++ b/backend/src/utils/hashHotlinkAccessToken.spec.ts @@ -2,11 +2,17 @@ import { createHash } from 'node:crypto'; import { hashHotlinkAccessToken } from './hashHotlinkAccessToken'; describe('hashHotlinkAccessToken', () => { - it('matches sha256 hex digest of the utf8 access token string', () => { + it('returns sha256 hex digest of the utf8 access token string', () => { const accessToken = 'test-hotlink-access-token_base64url'; expect(hashHotlinkAccessToken(accessToken)).toBe( createHash('sha256').update(accessToken, 'utf8').digest('hex') ); }); + + it('returns 64 lowercase hex characters', () => { + const hash = hashHotlinkAccessToken('ZWc6ttO-r2qssA_9gdWe6u14X2W0_j7xhRBSVQvfMKI'); + + expect(hash).toMatch(/^[a-f0-9]{64}$/); + }); }); diff --git a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts index c53a50e..b665581 100644 --- a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts +++ b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts @@ -4,4 +4,5 @@ export type CreateHotlinkUploadRequest = UploadOptionsRequest & { iv: string; ciphertext: string; mimeType: string; + accessTokenHash: string; }; diff --git a/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts b/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts new file mode 100644 index 0000000..7968f2b --- /dev/null +++ b/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken'; + +const hashAccessTokenLikeBackend = (accessToken: string): string => + createHash('sha256').update(accessToken, 'utf8').digest('hex'); + +const hotlinkAccessTokenHashVectors = [ + { + accessToken: 'test-hotlink-access-token_base64url', + accessTokenHash: '8defaf078afdd0ddabb77c73af4aa76ac78be24b6a6deffba2c6cfde43e20187' + }, + { + accessToken: 'ZWc6ttO-r2qssA_9gdWe6u14X2W0_j7xhRBSVQvfMKI', + accessTokenHash: 'cdb772980ea3e958cf74b62b70a2f310eafbb9bf3d2302356b602b4830e89bc6' + } +] as const; + +describe('hashHotlinkAccessToken', () => { + it.each(hotlinkAccessTokenHashVectors)( + 'matches fixed SHA-256 hex for $accessToken', + async ({ accessToken, accessTokenHash }) => { + await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(accessTokenHash); + } + ); + + it('matches backend hashHotlinkAccessToken (Node crypto) for arbitrary tokens', async () => { + const accessToken = 'another_base64url-style_secret-ABC123'; + + await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(hashAccessTokenLikeBackend(accessToken)); + }); + + it('returns 64 lowercase hex characters (SHA-256 digest)', async () => { + const hash = await hashHotlinkAccessToken(hotlinkAccessTokenHashVectors[0].accessToken); + + expect(hash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('changes when the access token string changes', async () => { + const [first, second] = hotlinkAccessTokenHashVectors; + + expect(first.accessTokenHash).not.toBe(second.accessTokenHash); + }); + + it('hashes the UTF-8 string, not base64url-decoded bytes', async () => { + const accessToken = 'AQ'; + + await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(hashAccessTokenLikeBackend(accessToken)); + }); +}); diff --git a/frontend/src/utils/crypto/hashHotlinkAccessToken.ts b/frontend/src/utils/crypto/hashHotlinkAccessToken.ts new file mode 100644 index 0000000..d513106 --- /dev/null +++ b/frontend/src/utils/crypto/hashHotlinkAccessToken.ts @@ -0,0 +1,9 @@ +export const hashHotlinkAccessToken = async (accessToken: string): Promise => { + const encoder = new TextEncoder(); + + const data = encoder.encode(accessToken); + + const digest = await crypto.subtle.digest('SHA-256', data); + + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); +}; diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts index 0453cab..f1c59ad 100644 --- a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts @@ -4,6 +4,7 @@ import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken'; import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest'; @@ -22,10 +23,30 @@ describe('prepareHotlinkUploadRequest', () => { ...options, mimeType: 'image/png', iv: request.iv, - ciphertext: request.ciphertext + ciphertext: request.ciphertext, + accessTokenHash: request.accessTokenHash }); + expect(request.accessTokenHash).toMatch(/^[a-f0-9]{64}$/); expect(request).not.toHaveProperty('secret'); - expect(Object.keys(request).sort()).toEqual(['ciphertext', 'expiresAt', 'iv', 'maxViews', 'mimeType']); + expect(Object.keys(request).sort()).toEqual([ + 'accessTokenHash', + 'ciphertext', + 'expiresAt', + 'iv', + 'maxViews', + 'mimeType' + ]); + }); + + it('includes accessTokenHash derived from secret without sending secret in the request', async () => { + const { request, secret } = await prepareHotlinkUploadRequest( + new Uint8Array([9]), + 'image/png', + options + ); + + await expect(hashHotlinkAccessToken(secret)).resolves.toBe(request.accessTokenHash); + expect(JSON.stringify(request)).not.toContain(secret); }); it('roundtrips plaintext using request ciphertext and path secret', async () => { diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts index 5135adf..a96d0fc 100644 --- a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts @@ -1,5 +1,6 @@ import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest'; import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; +import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken'; import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload'; @@ -9,6 +10,7 @@ export const prepareHotlinkUploadRequest = async ( options: UploadOptionsRequest ): Promise => { const encrypted = await encryptHotlinkUpload(plaintext); + const accessTokenHash = await hashHotlinkAccessToken(encrypted.secret); return { secret: encrypted.secret, @@ -16,7 +18,8 @@ export const prepareHotlinkUploadRequest = async ( ...options, iv: bytesToBase64(encrypted.iv), ciphertext: bytesToBase64(encrypted.ciphertext), - mimeType + mimeType, + accessTokenHash } }; };