diff --git a/backend/src/lib/password-variants.ts b/backend/src/lib/password-variants.ts new file mode 100644 index 0000000..b62d932 --- /dev/null +++ b/backend/src/lib/password-variants.ts @@ -0,0 +1,25 @@ +// Les claviers de téléphone remplacent ' par ’ et ajoutent parfois une espace après le dernier mot. +// Un mot de passe de partage tapé ainsi doit quand même ouvrir l'arbre : on essaie aussi ces variantes. +const CURLY_APOSTROPHES = /[‘’ʼ´`]/g +const CURLY_QUOTES = /[“”«»]/g + +export function sharePasswordVariants(input: string): string[] { + const trimmed = input.trim() + const straight = trimmed.replace(CURLY_APOSTROPHES, "'").replace(CURLY_QUOTES, '"') + const curly = trimmed.replace(/'/g, '’') + return [...new Set([input, trimmed, straight, curly])].filter(Boolean) +} + +// Renvoie la variante qui correspond à l'un des hash, ou null +export async function matchSharePassword( + input: string, + hashes: string[], + verify: (password: string, hash: string) => Promise, +): Promise { + for (const candidate of sharePasswordVariants(input)) { + for (const hash of hashes) { + if (await verify(candidate, hash)) return candidate + } + } + return null +} diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 07c7e18..fd3715e 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { env } from '../config/env.js' import { hashPassword, verifyPassword } from '../lib/auth.js' +import { matchSharePassword } from '../lib/password-variants.js' import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js' import { purgeTrees } from '../lib/tree-purge.js' import type { MembershipRole } from '../types/auth.js' @@ -695,9 +696,12 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { // Un seul mot de passe de partage (25/09/2026) : regarder et proposer. Les deux colonnes restent, // les arbres créés avant ont encore deux mots de passe différents, les deux ouvrent en contributeur. - const matches = - (await verifyPassword(payload.data.password, accessConfig.contributorHash)) || - (await verifyPassword(payload.data.password, accessConfig.visitorHash)) + // Apostrophe typographique ou espace finale ajoutées par un clavier de téléphone : acceptées (password-variants.ts) + const matches = await matchSharePassword( + payload.data.password, + [accessConfig.contributorHash, accessConfig.visitorHash], + verifyPassword, + ) if (!matches) { return reply.code(401).send({ error: 'invalid_password' }) @@ -895,11 +899,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'tree_not_found' }) } - const password = payload.data.password.trim() - const matches = - (await verifyPassword(password, accessConfig.contributorHash)) || - (await verifyPassword(password, accessConfig.visitorHash)) - if (!matches) { + // On garde la variante exacte qui correspond au hash, c'est elle que la famille doit recevoir + const password = await matchSharePassword( + payload.data.password, + [accessConfig.contributorHash, accessConfig.visitorHash], + verifyPassword, + ) + if (!password) { return reply.code(422).send({ error: 'password_mismatch' }) } diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 84af498..7ff044c 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -605,6 +605,38 @@ describe('API security and authz', () => { await app.close() }) + it('accepts the share password typed with a phone apostrophe and a trailing space', async () => { + const { hashPassword } = await import('../src/lib/auth') + const app = createApp() + await app.ready() + + const hash = await hashPassword("Lavenuedel'Avenir") + prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({ + treeId: 'tree-1', + visitorHash: hash, + contributorHash: hash, + updatedAt: new Date('2026-02-11T10:00:00.000Z'), + tree: { deletedAt: null }, + }) + + for (const typed of ['Lavenuedel’Avenir', "Lavenuedel'Avenir ", 'Lavenuedel’Avenir ']) { + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/access/unlock', + payload: { password: typed }, + }) + expect(response.statusCode, JSON.stringify(typed)).toBe(200) + } + + const wrong = await app.inject({ + method: 'POST', + url: '/trees/tree-1/access/unlock', + payload: { password: 'lavenuedel’avenir' }, + }) + expect(wrong.statusCode).toBe(401) + await app.close() + }) + it('still rejects a wrong share password', async () => { const { hashPassword } = await import('../src/lib/auth') const app = createApp() diff --git a/backend/test/password-variants.test.ts b/backend/test/password-variants.test.ts new file mode 100644 index 0000000..377a148 --- /dev/null +++ b/backend/test/password-variants.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { sharePasswordVariants } from '../src/lib/password-variants' + +describe('sharePasswordVariants', () => { + it('ajoute la forme droite et la forme typographique de l\'apostrophe', () => { + expect(sharePasswordVariants('Lavenuedel’Avenir')).toContain("Lavenuedel'Avenir") + expect(sharePasswordVariants("Lavenuedel'Avenir")).toContain('Lavenuedel’Avenir') + }) + + it('retire les espaces autour sans toucher à la casse', () => { + expect(sharePasswordVariants(' Soleil ')).toEqual([' Soleil ', 'Soleil']) + }) + + it('ne change rien à un mot de passe simple', () => { + expect(sharePasswordVariants('123soleil')).toEqual(['123soleil']) + }) +})