Skip to content

Rotate the INSEE password from a probed state - #383

Draft
skelz0r wants to merge 6 commits into
developfrom
feature/api-7345-rotation-du-mot-de-passe-insee-conception-post-incident-du
Draft

Rotate the INSEE password from a probed state#383
skelz0r wants to merge 6 commits into
developfrom
feature/api-7345-rotation-du-mot-de-passe-insee-conception-post-incident-du

Conversation

@skelz0r

@skelz0r skelz0r commented Sep 2, 2026

Copy link
Copy Markdown
Member

https://linear.app/pole-api/issue/API-7345

The September 1st outage came from a password derived from the clock and
pushed blindly: one failed rotation desynchronized us from INSEE with no way
back, since repairing the password requires a valid token, and the five
attempts retry loop matched INSEE's account locking threshold exactly.

INSEE's state is no longer guessed nor memoized, it is probed. Authentication
walks at most two candidates, current and previous, one attempt each, and only
falls back on invalid_grant: a timeout or a 5xx proves nothing about the
password and must not spend the failure budget. A single flight lock keeps the
concurrent threads of an instance to one OAuth call, and a thirty minutes
negative cache stops every incoming request from hammering a locked account.
Since one success resets INSEE's counter, no authentication ever exceeds one
consecutive failure as long as INSEE holds one of the two candidates. Both
applications share the same account, so they now share the same policy and the
same derivation code, locked on both sides by an identical known vector test.

Rotation leaves the request path entirely and becomes a daily GoodJob cron in
site production, the only place with a job framework and a writable database.
It is stateless and idempotent: it probes the current password and only renews
when INSEE still holds the previous one, so a failed rotation is retried every
day and alerts every time, well within the twenty nine days of margin left by
the ninety days validity. Four independent guards keep it to a single
execution: production only in the schedule, FRONTAL as HealthcheckJob does,
a GoodJob concurrency key, and the idempotence of the job itself. A
insee:rotate_from_bypass task hands the account back to derivation without a
service window, and is safe to replay.

Derivation itself starts on November 1st: until then every candidate list
collapses to the static credential, so this deployment changes no password and
costs no failed authentication.

@linear

linear Bot commented Sep 2, 2026

Copy link
Copy Markdown

API-7345

@gitguardian

gitguardian Bot commented Sep 2, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36839055 Triggered Generic Password 78b5d2f site/spec/clients/insee_password_renewal_spec.rb View secret
36839055 Triggered Generic Password 78b5d2f site/spec/clients/insee_password_renewal_spec.rb View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@skelz0r
skelz0r marked this pull request as draft September 2, 2026 12:52
@skelz0r
skelz0r force-pushed the feature/api-7345-rotation-du-mot-de-passe-insee-conception-post-incident-du branch 4 times, most recently from 78763f0 to 141f85f Compare September 7, 2026 10:24
The password was derived from the clock and pushed blindly: a single
failed rotation desynchronized the application from INSEE without any
way back, since repairing the password requires a valid token. The five
attempts retry loop then matched INSEE's account locking threshold
exactly, which is what turned that desynchronization into the outage of
September 1st.

INSEE's state is now probed instead of assumed. Authentication walks at
most two candidates, current and previous, one attempt each, and only
carries on to the next on invalid_grant: a timeout, a 5xx, a 429 or a
408 prove nothing about the password and must not spend the failure
budget. Since one success resets INSEE's counter, no authentication ever
exceeds one consecutive failure as long as INSEE holds one of the two
candidates. Any other 4xx is a refusal of the exchange itself -- a
revoked client secret, an unauthorized_client, a locked account -- and
stops the walk at once: the remaining candidates cannot help, and
spending them is exactly the run of consecutive failures that locks the
account. Reading a rate limiting as such a refusal would turn a
Retry-After of a few seconds into thirty minutes without Sirene, plus an
alert about credentials that were never in question.

Both candidates being refused does not conclude the walk either. The
daily rotation renews the password while requests keep coming, so an
authentication straddling a renewal sees the current password refused
before it and the previous one refused after, and would declare a
desynchronization INSEE knows nothing about. Nothing can coordinate the
two, since the rotation runs in the site application and the two share
no Redis, so the current password is probed one last time before
concluding -- skipped when it was already the last candidate tried,
where nothing can have changed since. It costs one extra refused grant
on the day the account really is desynchronized, against a false alert
and thirty minutes of outage on every rotation day.

A single flight lock keeps the concurrent threads of an instance to one
OAuth call, and a thirty minutes negative cache stops every incoming
request from hammering a locked account. That cache is read a second
time once the lock is held: two requests arriving together both find it
empty and race for the lock, and the loser would otherwise spend its own
candidates against an account the winner has just found refusing every
password. Rereading the token was not enough, a failed authentication
publishes no token, only the negative cache.

The negative cache lives in a stable "insee" namespace rather than the
default one, whose key prefix embeds the boot timestamp of the writing
process: a deployment must not resume spending grants against an account
just found locked. That also means Rails.cache.clear no longer wipes it
between examples, hence the explicit reset in the spec helper. Beware
that a per-call namespace replaces the store namespace instead of
nesting under it.

The lock, on the other hand, sits beside the token in the application
namespace, because it only means something to the processes that can
read what its holder publishes: a fleet-wide lock over a per-boot token
would make every loser fail without ever being able to read the winner's
token. The two applications share no Redis, so each spends its own
attempt budget against the common account -- which is why each stops so
early.

The lock carries the identifier of the request holding it and is only
released when it still matches, so an authentication outliving the TTL
cannot delete the lock a successor acquired meanwhile, which would open
the door to the concurrent password attempts the lock exists to prevent.
The TTL covers the worst case rather than expiring in the middle of it:
three attempts at ten seconds of connect plus ten of read.

Acquisition tells three states apart rather than two. The cache store
wraps every operation in a failsafe, so a nil return meant both "someone
else holds the lock" and "Redis is gone"; reading the second as the
first turned a cache blip into a total INSEE outage, each request
sleeping half a second before failing on a cache that could not answer.
SET NX separates them on its own, and with no cache to coordinate
through the request simply authenticates.

Invalidation takes the token the request actually sent and is a no-op
unless the cache still holds it. Five threads carrying the same revoked
token otherwise all fall through to reauthentication, and the losers
delete the token the winner has just stored -- one expiry event costing
as many grants as there are threads, with the single flight never
converging to one authentication.

Derivation guarantees one character of every class INSEE requires by
overwriting a position whose class has at least two members, which
always exists: sixteen characters spread over at most three present
classes leave one of them with a spare. Writing on a fixed position
instead would drop the sole member of a class already satisfied, and
INSEE rejects such a password: the raw encoding tSu1JGtkNfTLOXRQ only
lacks a special character, and writing '#' on index 3 dropped its single
digit. Over 50 000 periods, 342 of them (0.7%) came out that way.

Rotation leaves the request path entirely, along with the renewal
interactor: it now belongs to a daily job in the site application, which
shares the account and the derivation key. Derivation itself is deferred
to November, so the deployment is a no-op until then.
Both applications authenticate on the same INSEE account, so they must
agree on the password and on how many failures they are allowed to
spend. The derivation code is duplicated identically, locked on both
sides by the same known vector test: any divergence in the key handling
or in the formatting now breaks the CI instead of the account.

Authentication follows the same policy as siade: one attempt per
candidate, carrying on only on invalid_grant, stopping at once on any
other 4xx since a refused exchange cannot be helped by another password,
a single flight lock released only by the request that owns it and whose
TTL covers three attempts at five seconds each, and an alert naming both
hypotheses, a desynchronized password or a locked account.

A 429 and a 408 are the exceptions to that 4xx rule. The INSEE gateway
rate limits and answers a Retry-After in seconds; treating that as a
refused exchange would cost thirty minutes without Sirene and an alert
on credentials that were never in question. Both stop the
authentication the way a 5xx or a timeout does, without spending the
next candidate or arming the guard. Classifying a response that way is a
job of its own, so the OAuth call and its verdict live in
INSEEOAuthExchange, leaving this class with the token cache, the lock
and the failure guard.

Every candidate being refused does not conclude the walk either: the
daily rotation renews the password under the requests, and an
authentication straddling a renewal sees the current password refused
before it and the previous one refused after. Since the rotation shares
no cache with the request path, the current password is probed one last
time before declaring a desynchronization -- skipped when it was already
the last candidate tried, where nothing can have changed since.

The failure guard is read a second time once the lock is held. Two
requests arriving together both find it empty and race for the lock; the
loser would otherwise spend its own candidates against an account the
winner has just found refusing every password. Rereading the token was
not enough, a failed authentication publishes no token, only the guard.

The two applications share no Redis, so nothing is pooled between them:
each keeps its own token, its own lock and its own negative cache, and
spends its own attempt budget against the common account. The negative
cache sits in a stable "insee" namespace so that a deployment cannot
resume spending grants against an account just found locked, while the
lock stays beside the token, whose namespace is rebuilt at every boot --
a lock outliving what it protects would block requests that can never
read the token it was taken for.

Acquiring that lock tells three states apart rather than two, since the
cache store returns nil both when another process holds the lock and
when Redis is unreachable; reading the second as the first would turn a
cache blip into a total INSEE outage. With no cache to coordinate
through, the request authenticates directly.

Invalidation takes the token the caller actually sent and does nothing
unless the cache still holds it, so a request answering a stale 401
cannot erase a token a sibling published in the meantime. The guards are
releasable from a console: they are deliberately outside the application
cache prefix, where rake cache:clear cannot reach them, and an operator
who has repaired the credentials should not have to wait out the TTL.

The Faraday retry middleware is dropped, since it retried on
UnauthorizedError and ClientError, which are exactly the answers that
must never be retried. The token is cached until it expires instead of
being asked again on every call.
INSEESireneAPIClient raised on a 401 but kept the bearer in cache, so
every following request replayed the token INSEE had just refused, until
its announced expiration around seven days later. A password renewal or
a token revocation therefore broke the organisation payload jobs for a
week.

A 401 now drops the cached token and replays the request once, which
reauthenticates through the candidate list. The client remembers the
bearer it actually sent and hands it to the invalidation, so a request
answering a stale 401 cannot erase a token another request refreshed
between its own read and its rescue. siade already did this in
INSEE::MakeRequest.
siade has neither a job framework nor a writable database, site has
GoodJob with cron. Since both share the INSEE account and the derivation
key, site rotates the password for everyone.

INSEE::PasswordRotation holds the whole state machine: converge on the
current password, knowing INSEE holds either it or one fallback. Probe
the target first -- on a normal day it is granted and there is nothing
to do -- then the fallback, and renew from it. The job passes the
previous period's password as the fallback; an operator leaving the
bypass passes the bypass value. Expressing both as one operation is what
keeps them honest: a task duplicating this machine would drift from it,
and would inherit none of its guards.

The job is stateless and idempotent. Replayed the next day it converges,
so a failed rotation is retried daily and alerts every time until a
human steps in, well within the twenty nine days of margin left by the
ninety days validity. It costs one successful authentication on a normal
day, one failure plus one success plus one renewal on a rotation day,
and never more than two failures. Finding both passwords rejected does
not merely alert: it arms the same guard the request path arms, so the
applications stop spending candidates against an account just found
desynchronized instead of waiting for a request to discover it again.

Every provider failure is reported for what it is. An unavailable probe
proves nothing about the password it probed, on the previous one as on
the current one, so both take the warn and wait path rather than the
desynchronization alert. A renewal that never reaches INSEE is reported
like any other renewal failure instead of raising out of the job:
ApplicationJob defines no retry policy, so the exception would lose the
alert meant to fire on a failed rotation and leave the next daily run as
the only recourse.

It runs at 00:05 rather than a few minutes before midnight: rotating
early would leave INSEE on a password no application lists among its
candidates, hence a guaranteed double failure on any fresh
authentication. Four independent guards keep it to a single execution:
production only in the schedule, the FRONTAL environment variable as
HealthcheckJob does, a GoodJob concurrency key shared by every machine,
and the idempotence of the job itself.

Leaving the bypass runs the same code from a console. Going back to
derivation used to mean a window during which the applications
authenticate with a password INSEE no longer holds; the operation renews
from the bypass value to the derived one while both applications still
list the bypass first and the derived password second, so they keep
authenticating throughout, and the bypass credentials are only removed
afterwards. Replaying it is safe: a timeout may have gone through on
INSEE's side, in which case the bypass no longer authenticates while the
derived password does, which the first probe detects without calling the
renewal endpoint a second time. It refuses to run before the derivation
window opens, where the current password is still the static one and the
operation would hand the account back to the very value the bypass
exists to work around.
Authentication used to let Faraday errors bubble out of #access_token,
and UpdateOrganizationINSEEPayloadJob retried them forever. Wrapping the
OAuth call in an attempt that rescues Faraday::Error and re-raises
TemporaryError silently took that away: the job knows nothing about that
class, GoodJob does not retry what a job does not declare, so a single
5xx or timeout on the INSEE token endpoint now loses the payload.

Nothing enqueues that job again either, it only runs on a DataPass
webhook, so the organization would keep an empty insee_payload until the
next habilitation touches it.

Attempts are bounded, unlike the Faraday ones: a temporary error also
covers the thirty minutes of negative cache and the single flight
waiter, where retrying forever would only add noise to an outage that is
already reported.
ProviderAuthenticationError had a single emitter, INSEE::RenewPassword,
whose errors rotate_password_if_needed! discarded -- it only read
success? -- so subcode 01006 was unreachable and documented nowhere.
Authenticate now emits it, and MakeRequest concatenates its errors into
the organizer context, so it surfaces as a 502 on the fifteen Sirene
endpoints. SDK users would have met an undocumented code carrying no
retry semantics, where the temporary error they know does carry them.

Declaring it on the interactor rather than in the baseline list keeps it
on the INSEE endpoints alone. Both SDKs were regenerated and are
unchanged: they model errors by HTTP status and pass the code through,
without a table to keep in step.
@skelz0r
skelz0r force-pushed the feature/api-7345-rotation-du-mot-de-passe-insee-conception-post-incident-du branch from 1b6bb51 to 3e378ad Compare September 7, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant