From 0d20f903556d7b5471ec1ee6921736c96827a28b Mon Sep 17 00:00:00 2001 From: greyforreal Date: Sun, 18 Oct 2026 11:18:05 +0100 Subject: [PATCH] fix: address issues #736, #734, #735, #737 - #736: Fix missing imports in AuthService - Added RefreshTokenStoreService and ACCESS_TOKEN_TTL_SECONDS imports - Resolves TS2304 compilation errors - All dependent specs should now pass - #734: Add provider health reset endpoint - New POST /ipfs/providers/:name/health/reset endpoint (admin-guarded) - Calls resetProviderHealth() to clear accumulated metrics - Allows providers to recover from FAILED status - #735: Document CID computation limitation - Added comprehensive documentation about single-block assumption - Documents known size thresholds and chunking behavior - Explains mismatch risks for files > 256KB - Suggests mitigation strategies for future work - #737: Implement refresh token family revocation on reuse - Added tombstone system to detect already-rotated tokens - Calls revokeTokenFamily() when token reuse is detected - 5-minute tombstone window to catch replay attacks - Protects against stolen refresh token replay --- backend/src/auth/auth.service.ts | 1 + .../src/auth/refresh-token-store.service.ts | 20 +++++++++++++ backend/src/ipfs-pinning/cid.util.ts | 29 +++++++++++++++++++ .../ipfs-pinning/ipfs-pinning.controller.ts | 21 ++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 4365781..8cd6ee8 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -6,6 +6,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { getJwtVerificationSecrets } from '../config/env.config'; import { NonceStoreService } from './nonce-store.service'; import { JWT_ALGORITHM } from '../config/env.config'; +import { RefreshTokenStoreService, ACCESS_TOKEN_TTL_SECONDS } from './refresh-token-store.service'; const CHALLENGE_PREFIX = 'Sign this message to authenticate with TrustFlow: '; diff --git a/backend/src/auth/refresh-token-store.service.ts b/backend/src/auth/refresh-token-store.service.ts index 45ceb6b..da76a49 100644 --- a/backend/src/auth/refresh-token-store.service.ts +++ b/backend/src/auth/refresh-token-store.service.ts @@ -60,9 +60,22 @@ export class RefreshTokenStoreService { address: string, ): Promise<{ valid: boolean; newToken?: string; shouldRevoke?: boolean }> { const key = this.tokenKey(token); + const tombstoneKey = this.tombstoneKey(token); if (this.redis) { try { + // Check if token is in tombstone (already rotated) + const tombstone = await this.redis.get(tombstoneKey); + if (tombstone) { + // Token reuse detected! Revoke entire family + const parsed = JSON.parse(tombstone); + this.logger.warn( + `Refresh token reuse detected for ${this.maskAddress(address)}, revoking family ${parsed.familyId}`, + ); + await this.revokeTokenFamily(address, parsed.familyId); + return { valid: false, shouldRevoke: true }; + } + const data = await this.redis.getdel(key); if (!data) { return { valid: false }; @@ -76,6 +89,9 @@ export class RefreshTokenStoreService { return { valid: false }; } + // Store tombstone for reuse detection (5 min window) + await this.redis.set(tombstoneKey, data, 'EX', 300); + const newToken = crypto.randomBytes(32).toString('hex'); const newKey = this.tokenKey(newToken); const familyKey = this.familyKey(address, parsed.familyId); @@ -168,6 +184,10 @@ export class RefreshTokenStoreService { return `refresh-token:${token}`; } + private tombstoneKey(token: string): string { + return `refresh-tombstone:${token}`; + } + private familyKey(address: string, familyId: string): string { return `refresh-family:${address}:${familyId}`; } diff --git a/backend/src/ipfs-pinning/cid.util.ts b/backend/src/ipfs-pinning/cid.util.ts index ecf7381..e0e7987 100644 --- a/backend/src/ipfs-pinning/cid.util.ts +++ b/backend/src/ipfs-pinning/cid.util.ts @@ -55,3 +55,32 @@ export function isValidCidV1Raw(cid: string): boolean { if (cid.length !== CIDV1_RAW_SHA256_LENGTH) return false; return /^b[a-z2-7]+$/.test(cid); } + + +/** + * Issue #735: CID Computation Limitation Documentation + * + * IMPORTANT LIMITATION: + * + * computeCidV1Raw() assumes content fits in a single IPFS block (raw codec). + * This works correctly for small files but may produce mismatches with provider-computed + * CIDs for larger files that get chunked into multi-block UnixFS DAGs. + * + * Current MAX_BYTES = 10 MB, but IPFS typically chunks at ~256KB default. + * + * Known behavior: + * - Files < 256KB: Usually single block, CID matches providers + * - Files > 256KB: May be chunked, CID may not match provider-returned hash + * + * Mitigation needed: + * 1. Test with real providers at various sizes (1KB, 256KB, 1MB, 5MB, 10MB) + * 2. Either lower MAX_BYTES to safe ceiling OR implement full UnixFS chunking + * 3. Consider removing hard CID comparison in favor of provider.verify() + * + * Related methods that depend on this assumption: + * - PinataProvider.sendPin() - compares response.IpfsHash !== cid + * - InfuraProvider.sendPin() - similar comparison + * - Web3StorageProvider.sendPin() - similar comparison + * + * TODO: Complete spike test with real provider credentials and document findings + */ diff --git a/backend/src/ipfs-pinning/ipfs-pinning.controller.ts b/backend/src/ipfs-pinning/ipfs-pinning.controller.ts index 7e15a92..a48488a 100644 --- a/backend/src/ipfs-pinning/ipfs-pinning.controller.ts +++ b/backend/src/ipfs-pinning/ipfs-pinning.controller.ts @@ -119,3 +119,24 @@ export class IpfsPinningController { return this.pinningService.unpin(cid); } } + + @Post('providers/:name/health/reset') + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Reset provider health status (admin only)', + description: + 'Clears accumulated health metrics for a provider, allowing it to recover from FAILED status. ' + + 'Useful after manual recovery or to reset after a temporary outage.', + }) + @ApiParam({ name: 'name', example: 'pinata', description: 'Provider name' }) + @ApiResponse({ status: 200, description: 'Provider health reset successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized — valid JWT required' }) + @ApiResponse({ status: 403, description: 'Forbidden — admin privileges required' }) + async resetProviderHealth(@Param('name') name: string) { + if (!this.providerHealth) { + return { error: 'Provider health service not available' }; + } + await this.providerHealth.resetProviderHealth(name); + return { message: `Health metrics reset for provider: ${name}` }; + }