diff --git a/src/subdomains/generic/user/models/auth/__tests__/auth-lightning-signature.spec.ts b/src/subdomains/generic/user/models/auth/__tests__/auth-lightning-signature.spec.ts new file mode 100644 index 0000000000..b53c53a488 --- /dev/null +++ b/src/subdomains/generic/user/models/auth/__tests__/auth-lightning-signature.spec.ts @@ -0,0 +1,54 @@ +import { ConfigService } from 'src/config/config'; +import { AuthService } from '../auth.service'; + +// Regression guard for the custodial-Lightning sign-in bypass: an empty stored signature must never +// authenticate. verifySignature is private, so we exercise it via bracket access with a bare instance +// (the Lightning branch only uses getSignMessages + the static CryptoService.getBlockchainsBasedOn). +describe('AuthService custodial Lightning signature check', () => { + let service: AuthService; + + // LNNID + 66 alnum → recognised as a Lightning address by CryptoService + const lightningAddress = `LNNID${'A'.repeat(66)}`; + // 140 lowercase alnum → matches the custodial-Lightning signature shape + const validShapeSignature = 'a'.repeat(140); + + const verify = (signature: string, dbSignature: string | undefined, isSignUp = false): Promise => + ( + service as unknown as { + verifySignature: ( + address: string, + signature: string, + isCustodial: boolean, + key: string | undefined, + dbSignature: string | undefined, + blockchain: undefined, + isSignUp: boolean, + ) => Promise; + } + ).verifySignature(lightningAddress, signature, false, undefined, dbSignature, undefined, isSignUp); + + beforeAll(() => { + new ConfigService(); + }); + + beforeEach(() => { + service = Object.create(AuthService.prototype); + }); + + it('rejects sign-in when the stored signature is empty (account takeover guard)', async () => { + await expect(verify(validShapeSignature, '')).resolves.toBe(false); + await expect(verify(validShapeSignature, undefined)).resolves.toBe(false); + }); + + it('rejects sign-in when the signature does not match the stored one', async () => { + await expect(verify(validShapeSignature, 'b'.repeat(140))).resolves.toBe(false); + }); + + it('accepts sign-in when the signature matches a non-empty stored signature', async () => { + await expect(verify(validShapeSignature, validShapeSignature)).resolves.toBe(true); + }); + + it('accepts sign-up (establishes the first signature) even without a stored signature', async () => { + await expect(verify(validShapeSignature, undefined, true)).resolves.toBe(true); + }); +}); diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 69189c7154..cd9f471678 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -152,7 +152,7 @@ export class AuthService { const custodyProvider = await this.custodyProviderService.getWithMasterKey(dto.signature).catch(() => undefined); if ( !custodyProvider && - !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, undefined, dto.blockchain)) + !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, undefined, dto.blockchain, true)) ) { throw new BadRequestException('Invalid signature'); } @@ -475,14 +475,18 @@ export class AuthService { key?: string, dbSignature?: string, blockchain?: Blockchain, + isSignUp = false, ): Promise { const { defaultMessage, fallbackMessage } = this.getSignMessages(address); const blockchains = CryptoService.getBlockchainsBasedOn(address); if (blockchains.includes(Blockchain.LIGHTNING) && (isCustodial || /^[a-z0-9]{140,146}$/.test(signature))) { - // custodial Lightning wallet, only comparison check - return !dbSignature || signature === dbSignature; + // custodial Lightning wallet: no cryptographic check is possible, so the signature acts as a + // shared secret. On sign-up nothing is stored yet, so the first signature establishes it; on + // sign-in it must match a NON-EMPTY stored signature. An empty stored signature must never + // authenticate — otherwise any signature passes for an account whose credential was never set. + return isSignUp || (!!dbSignature && signature === dbSignature); } if (blockchains.includes(Blockchain.DEFICHAIN)) {