From bc227230f4193968d9c5833ec5720e8ac38b8102 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:11:25 +0200 Subject: [PATCH] fix(auth): reject custodial Lightning sign-in when stored signature is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifySignature returned `!dbSignature || signature === dbSignature` for the custodial-Lightning comparison branch. On sign-in, an account whose stored signature is empty/null (legacy accounts — see the 'temporary code to update empty signatures' self-heal in doSignIn) would authenticate with ANY signature matching the 140-146 alnum shape, then doSignIn persists the attacker's signature → permanent account takeover. Reachable via the public /auth/signIn (isCustodial defaults to false there, the regex is trivially satisfiable). A stored empty signature must never authenticate. Thread an isSignUp flag so sign-up still establishes the first signature (nothing stored yet) while sign-in requires an exact match against a non-empty stored signature — aligning Lightning with the DeFiChain branch right below, which already fails closed on an empty stored signature. --- .../auth-lightning-signature.spec.ts | 54 +++++++++++++++++++ .../generic/user/models/auth/auth.service.ts | 10 ++-- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/generic/user/models/auth/__tests__/auth-lightning-signature.spec.ts 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)) {