Skip to content

Commit 97083cc

Browse files
committed
feat: implement lifecycle event force execution and enhance security documentation
- Added a new endpoint to force the re-execution of lifecycle events for identities, allowing backend scripts to be reapplied without changing the current state. - Updated the LifecycleHooksService to include logic for handling forced lifecycle events and added error handling for non-existent identities. - Enhanced the SECURITY.md file with detailed information on the storage and encryption of HIBP password fingerprints, including re-check scheduling and key management.
1 parent 20b1348 commit 97083cc

5 files changed

Lines changed: 166 additions & 5 deletions

File tree

SECURITY.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,63 @@ Use this section to tell people how to report a vulnerability.
1616

1717
Tell them where to go, how often they can expect to get an update on a
1818
reported vulnerability, what to expect if the vulnerability is accepted or
19-
declined, etc.
19+
declined, etc.
20+
21+
## #145 Stockage des empreintes HIBP (Pwned Passwords)
22+
23+
Quand l'option "Stockage des empreintes HIBP (Pwned Passwords)" est activee (politique `pwnedRecheckEnabled=true`), Sesame Orchestrator stocke des empreintes de mots de passe dans l'historique afin de permettre un re-check planifie via HIBP.
24+
25+
### Qu'est-ce qui est stocke
26+
27+
Pour chaque mot de passe enregistre dans l'historique (collection `password-history`), on calcule :
28+
29+
1. `SHA-1(password)` en hexadecimal (en sortie convertie en majuscules).
30+
2. Chiffrement de cette empreinte via AES-256-GCM.
31+
3. Stockage du resultat chiffre dans le champ Mongo `hibpSha1Enc`.
32+
33+
L'UI ne consomme jamais les hash en clair : elle n'expose que des indicateurs derives (ex. `hasHibpFingerprint`, `hibpLastCheckAt`, `hibpPwnCount`).
34+
35+
### Format de chiffrement
36+
37+
Le champ `hibpSha1Enc` correspond a une chaine au format :
38+
39+
`<ivB64>.<tagB64>.<cipherB64>`
40+
41+
ou :
42+
43+
- `iv` : IV aleatoire de 12 octets (AES-GCM)
44+
- `tag` : tag d'authentification GCM
45+
- `cipher` : donnees chiffrees
46+
47+
### Cle de chiffrement : `SESAME_PASSWORD_HISTORY_HIBP_KEY`
48+
49+
Le chiffrement/dechiffrement repose sur l'ENV `SESAME_PASSWORD_HISTORY_HIBP_KEY` :
50+
51+
- soit une cle hexadecimale de 64 caracteres (32 bytes),
52+
- soit une cle base64 qui decode en 32 bytes.
53+
54+
Cette cle est utilisee cote serveur au moment :
55+
56+
- de l'enregistrement de `hibpSha1Enc`,
57+
- du cron de re-check (pour dechiffrer et recalculer le prefixe/suffixe SHA-1 a interroger dans HIBP).
58+
59+
Consequence importante : une rotation de cle sans re-encryptage des empreintes existantes rend le re-check impossible pour les anciennes entrees (elles seront traitees comme non dechiffrables).
60+
61+
### Re-check planifie (cron)
62+
63+
Le re-check est configure dans `apps/api/defaults/cron/identities-pwned-recheck.yml` :
64+
65+
- schedule : tous les jours a 03:00 (`0 3 * * *`)
66+
- handler : `identities-pwned-recheck`
67+
- filtre : candidates dont `hibpSha1Enc != null` et `hibpLastCheckAt` est soit absent, soit plus ancien que `pwnedRecheckMaxAgeSeconds`
68+
69+
Le traitement :
70+
71+
- dechiffre `hibpSha1Enc` pour recuperer le SHA-1,
72+
- decoupe le SHA-1 en `prefix` (5 premiers caracteres) et `suffix` (reste),
73+
- appelle l'endpoint HIBP "range" :
74+
- `GET https://api.pwnedpasswords.com/range/<prefix>`
75+
- (le matching se fait localement sur le suffix retourne par l'API),
76+
- ecrit :
77+
- `hibpLastCheckAt` = maintenant
78+
- `hibpPwnCount` = nombre d'occurrences si trouve, sinon 0 (ou `null` si dechiffrement impossible).

apps/api/src/management/lifecycle/lifecycle-hooks.service.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Injectable } from '@nestjs/common'
1+
import { Injectable, NotFoundException } from '@nestjs/common'
2+
import { Types } from 'mongoose'
23
import { OnEvent } from '@nestjs/event-emitter'
34
import { CronJob } from 'cron'
45
import dayjs from 'dayjs'
@@ -435,8 +436,26 @@ export class LifecycleHooksService extends AbstractLifecycleService {
435436
* // Si une règle OFFICIAL -> MANUAL existe et match
436437
* // L'identité est automatiquement transitionnée vers MANUAL
437438
*/
438-
private async fireLifecycleEvent(before: Identities, after: Identities): Promise<void> {
439-
const lifecycleChanged = !!before && before.lifecycle !== after.lifecycle
439+
/**
440+
* Force la réexécution des effets d'un changement de cycle de vie sans modifier l'état courant.
441+
*
442+
* Utile pour rejouer les scripts backend lorsque la configuration a évolué après le dernier passage.
443+
*/
444+
public async forceLifecycleEvent(identityId: Types.ObjectId): Promise<void> {
445+
const identity = await this.identitiesService.findById<Identities>(identityId)
446+
if (!identity) {
447+
throw new NotFoundException('Identity not found')
448+
}
449+
450+
await this.fireLifecycleEvent(identity, identity, { force: true })
451+
}
452+
453+
private async fireLifecycleEvent(
454+
before: Identities,
455+
after: Identities,
456+
options?: { force?: boolean },
457+
): Promise<void> {
458+
const lifecycleChanged = options?.force || (!!before && before.lifecycle !== after.lifecycle)
440459

441460
if (lifecycleChanged) {
442461
await this.create({

apps/api/src/management/lifecycle/lifecycle.controller.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Get,
44
HttpStatus,
55
Param,
6+
Post,
67
Query,
78
Req,
89
Res,
@@ -16,6 +17,7 @@ import { AbstractController } from '~/_common/abstracts/abstract.controller'
1617
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe'
1718
import { Lifecycle } from './_schemas/lifecycle.schema'
1819
import { LifecycleCrudService } from './lifecycle-crud.service'
20+
import { LifecycleHooksService } from './lifecycle-hooks.service'
1921
import { LifecycleCacheInterceptor } from './_interceptors/lifecycle-cache.interceptor'
2022
import { UseRoles } from '~/_common/decorators/use-roles.decorator'
2123
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types'
@@ -56,6 +58,7 @@ export class LifecycleController extends AbstractController {
5658
*/
5759
public constructor(
5860
protected readonly _service: LifecycleCrudService,
61+
private readonly lifecycleHooksService: LifecycleHooksService,
5962
) {
6063
super()
6164
}
@@ -87,6 +90,26 @@ export class LifecycleController extends AbstractController {
8790
* total: 2
8891
* }
8992
*/
93+
@Post('identity/:identityId/force')
94+
@UseRoles({
95+
resource: '/management/lifecycle',
96+
action: AC_ACTIONS.UPDATE,
97+
possession: AC_DEFAULT_POSSESSION,
98+
})
99+
@ApiOperation({ summary: 'Forcer la réexécution du cycle de vie courant' })
100+
@ApiParam({ name: 'identityId', description: 'Identifiant de l\'identité' })
101+
public async forceLifecycle(
102+
@Param('identityId', ObjectIdValidationPipe) identityId: Types.ObjectId,
103+
@Res() res: Response,
104+
): Promise<Response> {
105+
await this.lifecycleHooksService.forceLifecycleEvent(identityId)
106+
107+
return res.status(HttpStatus.OK).json({
108+
statusCode: HttpStatus.OK,
109+
message: 'Cycle de vie réexécuté',
110+
})
111+
}
112+
90113
@Get('identity/:identityId')
91114
@UseRoles({
92115
resource: '/management/lifecycle',

apps/web/src/pages/identities/table/[_id]/index.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,22 @@
156156
span(v-text='stateItem.label')
157157
| &nbsp;
158158
small(v-text='("(" + stateItem.key + ")")')
159+
q-separator
160+
q-item(
161+
clickable
162+
v-close-popup
163+
@click="forceLifecycleExecution()"
164+
:disable="loadingForceLifecycle"
165+
)
166+
q-item-section(avatar)
167+
q-icon(
168+
:name="loadingForceLifecycle ? 'mdi-loading' : 'mdi-replay'"
169+
:class="{ 'mdi-spinner-parent': loadingForceLifecycle }"
170+
color="purple-8"
171+
)
172+
q-item-section
173+
q-item-label Forcer l'exécution du cycle de vie
174+
q-item-label(caption) Réapplique les scripts backend pour l'état courant
159175
q-separator(v-for='_ in 2' :key='_' vertical)
160176
q-btn-dropdown(:class="[$q.dark.isActive ? 'text-white' : 'text-black']" dropdown-icon="mdi-dots-vertical" unelevated dense)
161177
q-list(dense)
@@ -242,6 +258,7 @@ export default defineNuxtComponent({
242258
validationsModal: false,
243259
resetPasswordModal: false,
244260
loadingSwitchStatus: false,
261+
loadingForceLifecycle: false,
245262
}
246263
},
247264
async setup() {
@@ -315,6 +332,49 @@ export default defineNuxtComponent({
315332
})
316333
}
317334
},
335+
async forceLifecycleExecution() {
336+
this.$q
337+
.dialog({
338+
title: 'Confirmation',
339+
message:
340+
"Voulez-vous forcer la réexécution du cycle de vie courant ? Les scripts backend seront relancés comme lors d'un nouveau changement d'état.",
341+
persistent: true,
342+
ok: {
343+
push: true,
344+
color: 'positive',
345+
label: 'Forcer',
346+
},
347+
cancel: {
348+
push: true,
349+
color: 'negative',
350+
label: 'Annuler',
351+
},
352+
})
353+
.onOk(async () => {
354+
this.loadingForceLifecycle = true
355+
try {
356+
await this.$http.post(`/management/lifecycle/identity/${this.identity._id}/force`)
357+
this.$q.notify({
358+
message: 'La réexécution du cycle de vie a été lancée',
359+
color: 'positive',
360+
position: 'top-right',
361+
icon: 'mdi-check-circle-outline',
362+
})
363+
;(this as any).refresh()
364+
} catch (error: any) {
365+
this.$q.notify({
366+
message:
367+
'Impossible de forcer la réexécution du cycle de vie : ' +
368+
(error?.response?._data?.message || error?.message || 'erreur inconnue'),
369+
color: 'negative',
370+
position: 'top-right',
371+
icon: 'mdi-alert-circle-outline',
372+
})
373+
} finally {
374+
this.loadingForceLifecycle = false
375+
}
376+
})
377+
},
318378
async doChangePassword() {
319379
try {
320380
const data = await this.$http.post('/management/identities/forcepassword', {

apps/web/src/pages/settings/password-policy.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
q-tooltip.text-body2(anchor="bottom middle" self="top middle" :offset="[0, 8]" v-else-if="!hibpKeyStatus.valid")
104104
span(v-text="hibpKeyStatus.reason || 'Clé SESAME_PASSWORD_HISTORY_HIBP_KEY invalide'")
105105
q-tooltip.text-body2(anchor="bottom middle" self="top middle" :offset="[0, 8]" v-else)
106-
| Active le stockage des empreintes SHA-1 chiffrées (non réversibles) dans l'historique des mots de passe pour permettre le re-check planifié.
106+
| Active le stockage d’empreintes SHA-1 chiffrées (chiffrées/déchiffrables côté serveur via SESAME_PASSWORD_HISTORY_HIBP_KEY) dans l'historique des mots de passe pour permettre le re-check planifié.
107107
q-toggle.col-12.col-sm-6.col-md-4.col-lg-3(
108108
:disable='!hasPermission("/settings/passwdadm", "update")'
109109
dense

0 commit comments

Comments
 (0)