Slave/shorten hotlink urls #15
@@ -2,11 +2,17 @@ import { createHash } from 'node:crypto';
|
|||||||
import { hashHotlinkAccessToken } from './hashHotlinkAccessToken';
|
import { hashHotlinkAccessToken } from './hashHotlinkAccessToken';
|
||||||
|
|
||||||
describe('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';
|
const accessToken = 'test-hotlink-access-token_base64url';
|
||||||
|
|
||||||
expect(hashHotlinkAccessToken(accessToken)).toBe(
|
expect(hashHotlinkAccessToken(accessToken)).toBe(
|
||||||
createHash('sha256').update(accessToken, 'utf8').digest('hex')
|
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}$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export type CreateHotlinkUploadRequest = UploadOptionsRequest & {
|
|||||||
iv: string;
|
iv: string;
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
accessTokenHash: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const hashHotlinkAccessToken = async (accessToken: string): Promise<string> => {
|
||||||
|
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('');
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
|||||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||||
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||||
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||||
|
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
|
||||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||||
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
|
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
|
||||||
|
|
||||||
@@ -22,10 +23,30 @@ describe('prepareHotlinkUploadRequest', () => {
|
|||||||
...options,
|
...options,
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
iv: request.iv,
|
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(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 () => {
|
it('roundtrips plaintext using request ciphertext and path secret', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
|
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
|
||||||
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
||||||
|
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
|
||||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const prepareHotlinkUploadRequest = async (
|
|||||||
options: UploadOptionsRequest
|
options: UploadOptionsRequest
|
||||||
): Promise<PreparedHotlinkUploadRequest> => {
|
): Promise<PreparedHotlinkUploadRequest> => {
|
||||||
const encrypted = await encryptHotlinkUpload(plaintext);
|
const encrypted = await encryptHotlinkUpload(plaintext);
|
||||||
|
const accessTokenHash = await hashHotlinkAccessToken(encrypted.secret);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
secret: encrypted.secret,
|
secret: encrypted.secret,
|
||||||
@@ -16,7 +18,8 @@ export const prepareHotlinkUploadRequest = async (
|
|||||||
...options,
|
...options,
|
||||||
iv: bytesToBase64(encrypted.iv),
|
iv: bytesToBase64(encrypted.iv),
|
||||||
ciphertext: bytesToBase64(encrypted.ciphertext),
|
ciphertext: bytesToBase64(encrypted.ciphertext),
|
||||||
mimeType
|
mimeType,
|
||||||
|
accessTokenHash
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user