From da60fddf3164f4df418f398819767a5533df28ac Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 11 Aug 2025 15:37:15 +0800 Subject: [PATCH 1/2] add signature logic --- src/stores/api/client.ts | 68 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/stores/api/client.ts b/src/stores/api/client.ts index 57725708..f0a7c2fc 100644 --- a/src/stores/api/client.ts +++ b/src/stores/api/client.ts @@ -32,13 +32,21 @@ export class ApiClient { private readonly basedURL: string; private readonly apiUsername: string; private readonly apiSignature: string; + private readonly nonce: string; + private readonly timestamp: string; constructor(options: ApiClientOptions) { this.env = options.env; + this.nonce = this.getNonce(); this.apiKey = options.apiKey; this.apiUsername = options.apiUsername; this.apiSignature = options.apiSignature; this.basedURL = `${import.meta.env.VITE_API_URL}`; + this.timestamp = Math.floor(Date.now() / 1000).toString(); + } + + private getNonce(): string { + return crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, '0'), ''); } private getCacheKey(url: string): string { @@ -71,19 +79,55 @@ export class ApiClient { const headers = new Headers(); headers.append('X-API-Key', this.apiKey); + headers.append('X-API-Nonce', this.nonce); + headers.append('X-Request-ID', this.nonce); headers.append('User-Agent', 'oullin/web-app'); + headers.append('X-API-Timestamp', this.timestamp); headers.append('X-API-Username', this.apiUsername); - headers.append('X-API-Signature', this.apiSignature); - headers.append('Content-Type', 'application/json'); return headers; } + private async sha256Hex(text: string): Promise { + const enc = new TextEncoder(); + + const buf = await crypto.subtle.digest('SHA-256', enc.encode(text)); + + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + private async hmacSha256Hex(secret: string, message: string): Promise { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { + name: 'HMAC', + hash: 'SHA-256', + }, + false, + ['sign'], + ); + + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message)); + + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + public async post(url: string, data: object): Promise { const headers = this.createHeaders(); const fullUrl = new URL(url, this.basedURL); + const content: string = JSON.stringify(data); + const signature = await this.getSignature('POST', fullUrl, content); + + headers.append('X-API-Signature', signature); + const response = await fetch(fullUrl.href, { method: 'POST', headers: headers, @@ -97,15 +141,33 @@ export class ApiClient { return await response.json(); } + private sortedQuery(u: string): string { + const params = new URL(u).searchParams; + + params.sort(); + return params.toString(); + } + + private async getSignature(method: string, uri: URL, body: string): Promise { + const content = await this.sha256Hex(body); + + const canonical = [method.toUpperCase(), uri.pathname || '/', this.sortedQuery(uri.href), this.apiUsername, this.apiKey, this.timestamp, this.nonce, content].join('\n'); + + return await this.hmacSha256Hex(this.apiKey, canonical); + } + public async get(url: string): Promise { const headers = this.createHeaders(); - const fullUrl = new URL(url, this.basedURL); const cached = this.getFromCache(url); + const fullUrl = new URL(url, this.basedURL); if (cached) { headers.append('If-None-Match', cached.etag); } + const signature = await this.getSignature('GET', fullUrl, ''); + headers.append('X-API-Signature', signature); + const response = await fetch(fullUrl.href, { method: 'GET', headers: headers, From 8af55d8ce2a3dd5b06ba9cd88a9616b961ab8f60 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 11 Aug 2025 16:54:11 +0800 Subject: [PATCH 2/2] fix nonce --- package-lock.json | 13 ++++++++ package.json | 1 + src/stores/api/client.ts | 53 +++++++++++++++------------------ src/stores/api/store.ts | 30 ++++++++++++------- tests/stores/api/client.test.ts | 1 - 5 files changed, 58 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index ded1e5e0..0568a0be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "lodash": "^4.17.21", "marked": "^16.0.0", "pinia": "^3.0.2", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -5858,6 +5859,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index 5e56e2d1..d2fba680 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lodash": "^4.17.21", "marked": "^16.0.0", "pinia": "^3.0.2", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/src/stores/api/client.ts b/src/stores/api/client.ts index f0a7c2fc..61f7e815 100644 --- a/src/stores/api/client.ts +++ b/src/stores/api/client.ts @@ -1,4 +1,5 @@ import { HttpError } from '@api/http-error.ts'; +import { v4 as uuidv4 } from 'uuid'; const ENV_PROD = 'production'; @@ -6,14 +7,12 @@ export const defaultCreds: ApiClientOptions = { env: import.meta.env.VITE_API_ENV as string, apiKey: import.meta.env.VITE_PUBLIC_KEY as string, apiUsername: import.meta.env.VITE_ACCOUNT_NAME as string, - apiSignature: import.meta.env.VITE_PUBLIC_SIGNATURE as string, }; export interface ApiClientOptions { env: string; apiKey: string; apiUsername: string; - apiSignature: string; } export interface CacheEntry { @@ -31,21 +30,17 @@ export class ApiClient { private readonly apiKey: string; private readonly basedURL: string; private readonly apiUsername: string; - private readonly apiSignature: string; - private readonly nonce: string; private readonly timestamp: string; constructor(options: ApiClientOptions) { this.env = options.env; - this.nonce = this.getNonce(); this.apiKey = options.apiKey; this.apiUsername = options.apiUsername; - this.apiSignature = options.apiSignature; this.basedURL = `${import.meta.env.VITE_API_URL}`; this.timestamp = Math.floor(Date.now() / 1000).toString(); } - private getNonce(): string { + public createNonce(): string { return crypto.getRandomValues(new Uint8Array(16)).reduce((s, b) => s + b.toString(16).padStart(2, '0'), ''); } @@ -79,8 +74,7 @@ export class ApiClient { const headers = new Headers(); headers.append('X-API-Key', this.apiKey); - headers.append('X-API-Nonce', this.nonce); - headers.append('X-Request-ID', this.nonce); + headers.append('X-Request-ID', uuidv4()); headers.append('User-Agent', 'oullin/web-app'); headers.append('X-API-Timestamp', this.timestamp); headers.append('X-API-Username', this.apiUsername); @@ -119,13 +113,28 @@ export class ApiClient { .join(''); } - public async post(url: string, data: object): Promise { + private getSortedQuery(u: string): string { + const params = new URL(u).searchParams; + + params.sort(); + return params.toString(); + } + + private async getSignature(method: string, uri: URL, body: string, nonce: string): Promise { + const content = await this.sha256Hex(body); + + const canonical = [method.toUpperCase(), uri.pathname || '/', this.getSortedQuery(uri.href), this.apiUsername, this.apiKey, this.timestamp, nonce, content].join('\n'); + + return await this.hmacSha256Hex(this.apiKey, canonical); + } + + public async post(url: string, nonce: string, data: object): Promise { const headers = this.createHeaders(); const fullUrl = new URL(url, this.basedURL); - const content: string = JSON.stringify(data); - const signature = await this.getSignature('POST', fullUrl, content); + const signature = await this.getSignature('POST', fullUrl, content, nonce); + headers.append('X-API-Nonce', nonce); headers.append('X-API-Signature', signature); const response = await fetch(fullUrl.href, { @@ -141,22 +150,7 @@ export class ApiClient { return await response.json(); } - private sortedQuery(u: string): string { - const params = new URL(u).searchParams; - - params.sort(); - return params.toString(); - } - - private async getSignature(method: string, uri: URL, body: string): Promise { - const content = await this.sha256Hex(body); - - const canonical = [method.toUpperCase(), uri.pathname || '/', this.sortedQuery(uri.href), this.apiUsername, this.apiKey, this.timestamp, this.nonce, content].join('\n'); - - return await this.hmacSha256Hex(this.apiKey, canonical); - } - - public async get(url: string): Promise { + public async get(url: string, nonce: string): Promise { const headers = this.createHeaders(); const cached = this.getFromCache(url); const fullUrl = new URL(url, this.basedURL); @@ -165,8 +159,9 @@ export class ApiClient { headers.append('If-None-Match', cached.etag); } - const signature = await this.getSignature('GET', fullUrl, ''); + const signature = await this.getSignature('GET', fullUrl, '', nonce); headers.append('X-API-Signature', signature); + headers.append('X-API-Nonce', nonce); const response = await fetch(fullUrl.href, { method: 'GET', diff --git a/src/stores/api/store.ts b/src/stores/api/store.ts index dbbff6c0..efd5915e 100644 --- a/src/stores/api/store.ts +++ b/src/stores/api/store.ts @@ -41,90 +41,100 @@ export const useApiStore = defineStore(STORE_KEY, { }, async getProfile(): Promise> { const url = 'profile'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getExperience(): Promise> { const url = 'experience'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getRecommendations(): Promise> { const url = 'recommendations'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getProjects(): Promise> { const url = 'projects'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getTalks(): Promise> { const url = 'talks'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getSocial(): Promise> { const url = 'social'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getEducation(): Promise> { const url = 'education'; + const nonce = this.client.createNonce(); try { - return await this.client.get>(url); + return await this.client.get>(url, nonce); } catch (error) { return parseError(error); } }, async getCategories(): Promise { const url = 'categories?limit=5'; + const nonce = this.client.createNonce(); try { - return await this.client.get(url); + return await this.client.get(url, nonce); } catch (error) { return parseError(error); } }, async getPosts(filters: PostsFilters): Promise { const url = 'posts?limit=5'; + const nonce = this.client.createNonce(); try { - return await this.client.post(url, filters); + return await this.client.post(url, nonce, filters); } catch (error) { return parseError(error); } }, async getPost(slug: string): Promise { const url = `posts/${slug}`; + const nonce = this.client.createNonce(); try { - return await this.client.get(url); + return await this.client.get(url, nonce); } catch (error) { return parseError(error); } diff --git a/tests/stores/api/client.test.ts b/tests/stores/api/client.test.ts index 32091b0b..6006c99c 100644 --- a/tests/stores/api/client.test.ts +++ b/tests/stores/api/client.test.ts @@ -6,7 +6,6 @@ const options: ApiClientOptions = { env: 'development', apiKey: 'k', apiUsername: 'u', - apiSignature: 's', }; const url = 'http://example.com/';