Mot de passe de partage : apostrophe de téléphone et espace finale acceptées

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-25 19:05:48 +02:00
1 parent 7a6a3a0942
commit 84d0637ab7
4 files changed
+89 -8

No files matched your search

+25
View File
@@ -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<boolean>,
): Promise<string | null> {
for (const candidate of sharePasswordVariants(input)) {
for (const hash of hashes) {
if (await verify(candidate, hash)) return candidate
}
}
return null
}
+14 -8
View File
@@ -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' })
}
@@ -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()
+18
View File
@@ -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'])
})
})