Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ';

Expand Down
20 changes: 20 additions & 0 deletions backend/src/auth/refresh-token-store.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -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}`;
}
Expand Down
29 changes: 29 additions & 0 deletions backend/src/ipfs-pinning/cid.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
21 changes: 21 additions & 0 deletions backend/src/ipfs-pinning/ipfs-pinning.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` };
}