From 27b0bc2551d6319cf4469d1a87de8cec04856a77 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 26 Oct 2026 14:58:25 +0100 Subject: [PATCH 1/4] docs: the sub of an id_token_hint is read for the client it was issued to, at the authorization and the end session endpoints The interactions, the pairwise subjects and the logout pages: a hint names the person signed in when its sub is the identifier its client holds of them, the subject itself for a public client and the identifier of its sector for a pairwise one, so the hint of a pairwise client proves who is signed in and which session ends as the hint of a public client does, and a sub that names nobody for that client is a hint of another person. Read against beffroi-auth/beffroi 9597ebd3. --- docs/oidc/interactions.md | 9 +++++---- docs/oidc/logout.md | 10 +++++----- docs/oidc/pairwise-subjects.md | 9 +++------ 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/oidc/interactions.md b/docs/oidc/interactions.md index 34655a6..5693453 100644 --- a/docs/oidc/interactions.md +++ b/docs/oidc/interactions.md @@ -39,7 +39,7 @@ first that answers sends the browser to the login page: | | The request | The browser is sent to log in | |---|---|---| | 1 | Any | When it holds no session of this tenant, or one that has ended or expired | -| 2 | `id_token_hint` | When the `sub` of the hint is not the subject identifier of the account signed in | +| 2 | `id_token_hint` | When the `sub` of the hint, read for the client of the request, names another person than the one signed in, or nobody | | 3 | `acr_values` or `claims` asking for an `acr` | When the session satisfies none of the classes asked for, and a credential of this person would: a step-up | | 4 | `prompt=login` | Always | | 5 | `prompt=select_account` | Always: there is no account chooser, so it is a login again | @@ -52,9 +52,10 @@ stronger sign-in than the one the person has, with the credentials they hold tha the password, or the password followed by a code. A login of the person already signed in adds what it proved to the session the browser holds; a login of somebody else ends that session first. -The `id_token_hint` is compared with the subject identifier the account carries, which is the `sub` a client -of the `public` kind holds. An `id_token_hint` carrying the `sub` of a [pairwise](pairwise-subjects.md) -client never names the account signed in, so a request carrying one is sent to the login page every time. +The `sub` of an `id_token_hint` is the one the provider gave the client of the request, and it is read for +that client, as every `sub` a client presents is ([the way back](pairwise-subjects.md#the-way-back)): the +subject the account carries for a client of the `public` kind, the identifier of its sector for a +[pairwise](pairwise-subjects.md) one. A `sub` that names nobody for that client is a hint of another person. ### The class asked for diff --git a/docs/oidc/logout.md b/docs/oidc/logout.md index a5be574..56e9835 100644 --- a/docs/oidc/logout.md +++ b/docs/oidc/logout.md @@ -44,11 +44,11 @@ The session is the one the browser holds with the tenant. - **No active session:** there is nothing to end. The browser is sent to the `post_logout_redirect_uri` with its `state`, or shown the page that says it is signed out. -- **A hint that proves the session:** its `sub` is the subject of the account signed in and its `sid` the - session the browser holds. The session ends at once, without a question. The `sub` is compared as it is, - with the subject the account carries, which is the `sub` a client of public identifiers receives: the hint - of a client that receives pairwise identifiers proves no session, and its person is always asked. -- **Anything else:** no hint, a hint that could not be verified, a hint of another person, a hint of another +- **A hint that proves the session:** its `sub`, read for the client the hint was issued to, names the person + signed in, and its `sid` is the session the browser holds. The session ends at once, without a question. A + client of [pairwise](pairwise-subjects.md) identifiers proves it with the identifier of its sector, as a + client of public identifiers does with the subject the account carries. +- **Anything else:** no hint, a hint that could not be verified, a hint of another person or of nobody, a hint of another session or with no `sid`. The person is asked, on a page that names the client when one is named and the person signed in, with two buttons, *Sign out* and *Stay signed in*. The page posts the same parameters back with the answer and a form token; a form posted without a valid token is refused with a page, status diff --git a/docs/oidc/pairwise-subjects.md b/docs/oidc/pairwise-subjects.md index 59674d0..557403f 100644 --- a/docs/oidc/pairwise-subjects.md +++ b/docs/oidc/pairwise-subjects.md @@ -94,16 +94,13 @@ client that presents it, in that client's sector and in no other: - a `login_hint` read as a `sub` ([interactions](interactions.md)) names somebody only when the client sending it was given that identifier, or a client of its sector was; +- an `id_token_hint`, at the authorization endpoint ([interactions](interactions.md)) and at the end session + endpoint ([logout](logout.md)), names the person signed in when its `sub` is the identifier the client it was + issued to holds of that person; - the hints of a [backchannel authentication](grants/backchannel-authentication.md) request are read the same way; - a self-contained access token somebody signed in for is found to be that person's by its `sub`, read for the client it was issued to. An opaque access token names its person in its record, and a self-contained one whose `sub` another client named carries its person sealed, so neither is looked up. -!!! note "`id_token_hint` at the authorization endpoint" - - The authorization endpoint compares the `sub` of an `id_token_hint` with the subject identifier the account - carries, which is what a `public` client holds. A pairwise client that sends an `id_token_hint` there is sent - to the login page every time, and answered `login_required` when the request says `prompt=none`. - [:beffroi-arrow-right: Clients](clients.md) and [scopes and claims](scopes-and-claims.md) From efaecbe1001b7775ccfc78b1d3ad69fb1c62ee93 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 26 Oct 2026 14:58:44 +0100 Subject: [PATCH 2/4] docs: a client held to signed request objects registers none that is read unsigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request objects page, the rules a registration has to hold and the reference of the client options: require_signed_request_object, the member of RFC 9101 §10.5, is refused beside request_object_signing_alg: none, the one value under which an unsigned object is read, so that every object read for such a client is one it signed. Read against beffroi-auth/beffroi 9597ebd3. --- docs/oidc/clients.md | 2 ++ docs/oidc/request-objects.md | 4 ++-- docs/reference/configuration.md | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/oidc/clients.md b/docs/oidc/clients.md index 9b343da..15b9113 100644 --- a/docs/oidc/clients.md +++ b/docs/oidc/clients.md @@ -210,6 +210,8 @@ call for, or call for and lack; the rule a public client's loopback addresses fo - The token exchange grant and the `token_exchange` member come together or not at all, and the token types it names are ones the provider reads. - `requires_pkce: false` is refused to a public client. +- A client with `require_signed_request_object` (RFC 9101 §10.5) does not register + `request_object_signing_alg: none`, the one value under which an unsigned request object is read. - `backchannel_logout_session_required` needs a `backchannel_logout_uri`, and `frontchannel_logout_session_required` a `frontchannel_logout_uri`. - An encryption names its `alg` and its `enc` together. OIDC Registration §2 would default the `enc` to diff --git a/docs/oidc/request-objects.md b/docs/oidc/request-objects.md index d887518..1e35fd5 100644 --- a/docs/oidc/request-objects.md +++ b/docs/oidc/request-objects.md @@ -40,9 +40,9 @@ what it carried, so the authorization endpoint reads a pushed request as it read |---|---| | `request_object_signing_alg` absent | Signed under any algorithm the provider verifies: `RS256`, `PS256`, `ES256` or `EdDSA`. Never unsigned | | `request_object_signing_alg` naming one of those | Signed under that algorithm and no other. The client publishes its keys in `jwks` or behind a `jwks_uri` | -| `request_object_signing_alg: none` | Unsigned, and read all the same: the one exception, registered client by client, never a switch of the deployment | +| `request_object_signing_alg: none` | Unsigned, and read all the same: the one exception, registered client by client, never a switch of the deployment, and never beside `require_signed_request_object` | | `request_uris` | The URLs its objects may be read back from, compared byte for byte, fragment included | -| `require_signed_request_object` | Its authorization requests are read from a request object and from nothing else, signed as its `request_object_signing_alg` says. A request without one is refused on a page, with `invalid_request`, before its redirect URI is read. Its backchannel authentication requests are read from an object it signed, and from nothing else | +| `require_signed_request_object` | Its authorization requests are read from a request object and from nothing else (RFC 9101 §10.5), signed as its `request_object_signing_alg` says, which names an algorithm or is left out: beside `none`, the registration is refused. A request without one is refused on a page, with `invalid_request`, before its redirect URI is read. Its backchannel authentication requests are read from an object it signed, and from nothing else | An object signed under an algorithm the client did not register is refused unread, whatever its signature is worth. The discovery document lists in `request_object_signing_alg_values_supported` the algorithms the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8820590..b0ed0c4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -253,7 +253,7 @@ refused when the provider first reads its clients, naming the client, the option | `require_pushed_authorization_requests` | boolean | `false` | The client starts an authorization request only by pushing it (RFC 9126 §6), and any other request of it is refused on a page before its redirect URI is read. The discovery document announces the requirement when every client of the tenant carries it | | `require_sender_constrained_tokens` | boolean | `false` | A token request of the client proves a certificate (RFC 8705 §3) or a key (RFC 9449 §5), or it is refused rather than answered with bearer tokens (FAPI 2.0 Security Profile §5.3.1) | | `tls_client_certificate_bound_access_tokens` | boolean | `false` | The tokens of the client are bound to the certificate it presents at the TLS handshake, whichever way it authenticated (RFC 8705 §3.4), and a token request that proves no certificate is refused. A client authenticating by certificate gets bound tokens either way | -| `require_signed_request_object` | boolean | `false` | A request of the client is read only from a request object it signed, wherever the object arrives: in the query, behind a `request_uri`, in a pushed request or in a backchannel authentication request. An unsigned authorization request is refused on a page before its redirect URI is read, an unsigned backchannel one with `invalid_request` | +| `require_signed_request_object` | boolean | `false` | A request of the client is read only from a request object it signed (RFC 9101 §10.5), wherever the object arrives: in the query, behind a `request_uri`, in a pushed request or in a backchannel authentication request. An unsigned authorization request is refused on a page before its redirect URI is read, an unsigned backchannel one with `invalid_request`. A declaration that names it beside `request_object_signing_alg: none` is refused | | `id_token_signed_response_alg` | string | the default algorithm, `RS256` | The JWS algorithm the ID tokens and the logout tokens of the client are signed with (OIDC Registration §2), among the wired ones: `RS256`, `PS256`, `ES256` and `EdDSA` in the bundle, the first being the default | | `id_token_encrypted_response_alg` | string | none | The JWE key management algorithm the signed ID token is encrypted to a key of the client with (OIDC Core §3.1.3.7), among the wired ones: `ECDH-ES+A256KW` and `RSA-OAEP-256` in the bundle. Named with `id_token_encrypted_response_enc` or not at all. Left out, the ID token is signed and not encrypted | | `id_token_encrypted_response_enc` | string | none | The JWE content encryption algorithm of that ID token, among the wired ones: `A256GCM` in the bundle. OIDC Registration §2 defaults it to an algorithm the bundle does not wire, so it is written whenever `id_token_encrypted_response_alg` is | @@ -273,7 +273,7 @@ refused when the provider first reads its clients, naming the client, the option | `subject_type` | string | the first wired kind, `public` | The kind of `sub` the client sees for a person (OIDC Core §8): `public` or `pairwise` in the bundle. A `pairwise` client has a sector: its `sector_identifier_uri`, redirect URIs on one host, or, with no redirect URI, its `jwks_uri` | | `sector_identifier_uri` | string | none | A URL whose host is the sector of the client's pairwise subjects (OIDC Core §8.1). The document behind it is read when a client registers itself (OIDC Registration §5), and never for a declared client | | `initiate_login_uri` | string | none | Where a third party sends a person to have the client begin a login (OIDC Core §4): `https` on every host, a loopback host included (OIDC Registration §2) | -| `request_object_signing_alg` | string | any wired algorithm | The JWS algorithm the request objects of the client are signed with (OIDC Registration §2), among the wired ones. Left out, any of them verifies an object, and an unsigned object is refused. `none` is the one exception: the objects of the client carry no signature and are read all the same | +| `request_object_signing_alg` | string | any wired algorithm | The JWS algorithm the request objects of the client are signed with (OIDC Registration §2), among the wired ones. Left out, any of them verifies an object, and an unsigned object is refused. `none` is the one exception: the objects of the client carry no signature and are read all the same, and a client with `require_signed_request_object` cannot name it | | `backchannel_token_delivery_mode` | string | none | How the client learns that a backchannel authentication request was answered (CIBA Core §4): `poll` or `ping` in the bundle. There is no default: a client that names none is not a client of that flow, and the backchannel authentication endpoint refuses it | | `backchannel_client_notification_endpoint` | string | none | Where the provider posts to a `ping` client once its request is answered (CIBA Core §7.1, §10.2). Required with `ping`, and refused with any other mode or with none | | `resources` | list of strings | none | The resources the client may ask an access token for (RFC 8707 §2), each an absolute URI without a fragment; any other is refused with `invalid_target`. A client that registers none asks for none, and its tokens are addressed to itself | From e571628d445474bce2ac2d3d90902e408285587d Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 26 Oct 2026 15:00:31 +0100 Subject: [PATCH 3/4] docs: every data key is bound to its row under a secret the database does not hold, and that secret rotates by a rewrap Personal data: each row of a data key is bound to its reference and its scope by an HMAC under a key derived from the data key and a secret of beffroi.keys.data_key_secrets, the secret of the kernel by default, so a row edited, copied or planted opens nothing; the first secret binds and every one is read, and a secret is replaced by writing the new one first, rewrapping towards the client that already wraps the keys, then dropping the old one. The keys page, the configuration reference, the configuration and console pages of the bundle and its key management page name the setting. The application: APP_SECRET binds the data keys, is refused empty by their store, rotates only that way, and is part of what a restore needs. Read against beffroi-auth/beffroi 9597ebd3. --- docs/application/configuration.md | 2 +- docs/application/operating.md | 14 +++++++++--- docs/core/keys.md | 1 + docs/core/personal-data.md | 30 +++++++++++++++++++++++++ docs/reference/configuration.md | 1 + docs/symfony-bundle/configuration.md | 5 +++++ docs/symfony-bundle/console-commands.md | 4 +++- docs/symfony-bundle/key-management.md | 2 +- 8 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/application/configuration.md b/docs/application/configuration.md index 9f152a3..3529e7d 100644 --- a/docs/application/configuration.md +++ b/docs/application/configuration.md @@ -40,7 +40,7 @@ for a restart of the processes and not for a new image. | Variable | In `.env` | What it decides | |---|---|---| | `APP_ENV` | `dev` | The environment of the kernel. The production image sets `prod` | -| `APP_SECRET` | empty | The secret of the kernel, `framework.secret`. `.env.dev` and `.env.test` give one each, for their own environment, so a production environment names its own | +| `APP_SECRET` | empty | The secret of the kernel, `framework.secret`. `.env.dev` and `.env.test` give one each, for their own environment, so a production environment names its own. It is also the secret every data key of personal data is bound to its row under, `beffroi.keys.data_key_secrets` naming no other: the store of the data keys refuses an empty one the first time it is used, and a new one replaces it only as [operating it](operating.md#data-keys) says | | `APP_SHARE_DIR` | `var/share` | The share directory of Symfony, one per environment beneath it, `var/share/prod` for a production kernel. It holds the filesystem pool of `cache.app`, and with it the [caches of the bundle](deployment.md#what-the-processes-share) | | `DEFAULT_URI` | `https://op.localhost` | The URL Symfony generates absolute URLs under outside a request, `framework.router.default_uri` | | `DEFAULT_LOCALE` | `en` | The language a page falls back to, among the two the application serves, `en` and `fr` | diff --git a/docs/application/operating.md b/docs/application/operating.md index b4b72c6..c7a28db 100644 --- a/docs/application/operating.md +++ b/docs/application/operating.md @@ -80,13 +80,20 @@ by the master key of the tenant. | Command | What it does | |---|---| | `beffroi:data-keys:list` | Lists the data keys of the tenant by scope, with when each was minted, the master key that wraps it and the key management client that opens it. Reads only, and never opens a key | -| `beffroi:data-keys:rewrap --to=` | Wraps the data keys of the tenant with another client from then on, or with another master key of the same one, `--from` narrowing it to the keys one client holds and `--dry-run` listing what would move. No sealed value is read or rewritten, and the master key is derived from `BEFFROI_KMS_KEY_ID` rather than asked for | +| `beffroi:data-keys:rewrap --to=` | Wraps the data keys of the tenant with another client from then on, or with another master key of the same one, `--from` narrowing it to the keys one client holds and `--dry-run` listing what would move. No sealed value is read or rewritten, and the master key is derived from `BEFFROI_KMS_KEY_ID` rather than asked for. Each key it moves is bound again under the first secret of `beffroi.keys.data_key_secrets` | | `beffroi:data-keys:destroy` | Erases the data keys of the tenant, which makes every personal datum it had sealed unreadable for good. It asks for the slug of the tenant to be typed back, and cannot be undone | The clients of this application are `op`, which every key of the tenant is sealed with, and `second`, which is built from `BEFFROI_KMS_DSN_SECOND` when that variable names one. Nothing schedules a rewrap: a person asks for it. +The data keys are bound under `APP_SECRET`, which `beffroi.keys.data_key_secrets` takes as long as +`config/packages/beffroi.yaml` names no secret of its own. A new value replaces it in the three steps of +[personal data](../core/personal-data.md#rotating-the-secret-that-binds-them): `%kernel.secret%` first and the +previous value second in `beffroi.keys.data_key_secrets`, given to every process; `beffroi:data-keys:rewrap` +towards the client that wraps the keys, `op` unless they were moved; then the previous value dropped. Changed +alone, it leaves every sealed value unreadable. + ## The data downloaded | Command | When it is run by hand | Its timed step | @@ -127,16 +134,17 @@ journal, `load.budget_spent`. ## What to back up -One directory to back up, one key management service to lose. +One directory to back up, one key management service and one secret to keep. | What | Where | What it holds | |---|---|---| | The database | PostgreSQL | Everything the model writes, the personal data sealed in their columns, and the data keys wrapped by the master key | | The sealed keys | `var/keys` | One file per key of the tenant, and the pairwise salt of the tenant beside them, each sealed by the key management service | | The master key | The key management service | What opens all of the above | +| The secret of the kernel | `APP_SECRET`, in the environment of every process | What every data key is bound to its row under: under another secret, the database opens no sealed value | No command restores a deployment: a restore is the database and `var/keys` put back together, under the same -master key. The pairwise subjects of OpenID Connect and the persistent identifiers of SAML are computed from +master key and the same `APP_SECRET`. The pairwise subjects of OpenID Connect and the persistent identifiers of SAML are computed from the salt and never drawn, so they are the same after a restore that brings the salt back with the database. The GeoLite2 databases in `var/geoip` and the FIDO metadata in `var/webauthn` are written again by the commands that wrote them. diff --git a/docs/core/keys.md b/docs/core/keys.md index 79d26d5..95513a4 100644 --- a/docs/core/keys.md +++ b/docs/core/keys.md @@ -69,6 +69,7 @@ beffroi: |---|---|---| | `kms` | required | The key management client that seals the keys, an entry of `key_management.clients` | | `key_id` | required | The master key of that service the keys are sealed under, `{tenant}` standing for the slug of the tenant, one master key each | +| `data_key_secrets` | `['%kernel.secret%']`, the secret of the kernel | The secrets every data key of [personal data](personal-data.md#the-data-keys-of-a-tenant) is bound to its row under, the first binding a new one | | `storage` | `%kernel.project_dir%/var/keys` | The directory of the sealed keys, one JSON file per key under the slug of its tenant, written through a sibling file renamed into place | | `jwks_max_age` | `300` | How long a client may cache the JWK Set, in seconds, which the staging delay derives from | | `staging_delay` | `jwks_max_age` plus sixty seconds | How long a new key is published before it is used, in seconds | diff --git a/docs/core/personal-data.md b/docs/core/personal-data.md index 587965a..3a14e78 100644 --- a/docs/core/personal-data.md +++ b/docs/core/personal-data.md @@ -100,6 +100,14 @@ under. A process opens each data key once and keeps it, so the key management se once per value. Nothing in the ordinary life of a tenant removes a data key: a new key is minted beside the ones there, and a rewrap changes what protects a key and not the key. +Each row is also bound to its reference and its scope: an HMAC-SHA256 of the two, under a key derived from the +data key and from a secret the database does not hold. A row edited, copied under another reference or scope, +or written into the table by whoever can write the database opens nothing, even when the key it carries is +wrapped under a master key the provider may use. The secrets are those of `beffroi.keys.data_key_secrets`, the +secret of the kernel unless the configuration names others, one list for the whole deployment: the first one +binds a new row, and a row bound under any of them opens. A row bound under a secret that is no longer listed +opens nothing, and neither does any value sealed under its key. + ``` bash bin/console beffroi:data-keys:list ``` @@ -134,6 +142,28 @@ scopes the tenant holds. One tenant at a time, by hand: nothing schedules it. The keys of the tenant itself, its signing and encryption keys, are sealed apart, one file each, and no command rewraps them: a key sealed by another service is replaced by a [rotation](keys.md#rotating-without-an-outage). +## Rotating the secret that binds them + +``` yaml title="config/packages/beffroi.yaml" +beffroi: + keys: + data_key_secrets: + - '%env(DATA_KEY_SECRET)%' # the new secret, which binds + - '%env(PREVIOUS_DATA_KEY_SECRET)%' # the one it replaces, still read +``` + +1. Write the new secret first and keep the one it replaces second, and give that configuration to every + process of the deployment before going on: a process that does not list a secret opens no row bound under + it. The two variables are the deployment's own, and the bundle reads none of them by name. +2. Run `beffroi:data-keys:rewrap` with `--to` naming the client that already wraps the keys of the tenant, the + one `beffroi.keys.kms` names unless a rewrap moved them. Every key is bound again under the first secret as + it moves, and its value and every ciphertext sealed under it stay as they are. +3. Drop the old secret once the command has moved every key. + +The secret of the kernel is replaced the same way when the configuration names no secret of its own, +`%kernel.secret%` written first and its previous value second: changed alone, it leaves every sealed value of +the deployment unreadable. + ## Destroying them ``` bash diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b0ed0c4..88fa358 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -64,6 +64,7 @@ key of the tenant but its SAML keys, which take theirs from |---|---|---|---| | `kms` | string, not empty | required | The name of the key management client the private keys are sealed with, an entry of `key_management.clients`. A name nothing is registered under fails the build of the container, naming the clients that are registered. The client seals and opens keys, and never signs | | `key_id` | string, not empty | required | The key of that client the keys of the tenant are sealed under, in the terms of its backend: the identifier of a sodium key, a Vault transit key name, an ARN. `{tenant}` in it stands for the slug of the tenant, one master key per tenant. The data keys that seal personal data are wrapped under the same key | +| `data_key_secrets` | list of strings, not empty, one at least | `['%kernel.secret%']`, the secret of the kernel | The secrets every data key of personal data is bound to its reference and its scope under, which the database does not hold. The first binds a new key, a key bound under any of them opens, and one bound under a secret no longer listed opens nothing, nor does what it sealed. A single string is read as a list of one. A secret is replaced by writing the new one first, then running `beffroi:data-keys:rewrap` towards the client that already wraps the keys, then dropping the old one: [personal data](../core/personal-data.md#rotating-the-secret-that-binds-them) | | `storage` | string, not empty | `%kernel.project_dir%/var/keys` | The directory of the sealed keys, one file per key and per tenant, and of the pairwise salt of each tenant beside them (OIDC Core §8.1) | | `jwks_max_age` | integer, 0 or more | `300`, five minutes | How long a relying party may keep the JWK Set (RFC 7517 §5): its `Cache-Control: public, max-age`, which the SAML metadata are served with as well. The default staging delay derives from it | | `staging_delay` | integer, 0 or more | `jwks_max_age` plus 60, six minutes with the default | How long a new key is published before it signs. The default covers a relying party holding a copy of the JWK Set as old as `jwks_max_age`, and the sixty seconds after which Symfony's `oidc_login` reads the set again for a `kid` it does not hold | diff --git a/docs/symfony-bundle/configuration.md b/docs/symfony-bundle/configuration.md index ec485c3..09492c9 100644 --- a/docs/symfony-bundle/configuration.md +++ b/docs/symfony-bundle/configuration.md @@ -117,6 +117,11 @@ beffroi: way. The salt is drawn on first use and never again, and every pairwise subject of the tenant is computed from it, so the directory is part of what a deployment backs up. +`data_key_secrets` lists the secrets every data key of personal data is bound to its row under, which the +database does not hold, and is the secret of the kernel when it is left out. The first binds a new key and a +key bound under any of them opens, so a secret is replaced by writing the new one first, as [personal +data](../core/personal-data.md#rotating-the-secret-that-binds-them) says. + The three other options time the rotation of every key but the SAML keys. A relying party keeps the JWK Set for `jwks_max_age`, five minutes. A new key is published for `staging_delay` before it signs, by default `jwks_max_age` plus the sixty seconds after which Symfony's `oidc_login` reads the set again for a `kid` it diff --git a/docs/symfony-bundle/console-commands.md b/docs/symfony-bundle/console-commands.md index eafd9b1..4723ff4 100644 --- a/docs/symfony-bundle/console-commands.md +++ b/docs/symfony-bundle/console-commands.md @@ -122,7 +122,9 @@ key, a data key being minted at the first write of its family. The command never **`beffroi:data-keys:rewrap`** wraps the data keys of the tenant with another client from now on, or with another master key of the same one. No sealed value is read or rewritten: what changes is how a data key is -protected, and every ciphertext goes on naming the same key. +protected, and every ciphertext goes on naming the same key. Each key it moves is bound again under the first +secret of `beffroi.keys.data_key_secrets`, so the command run towards the client that already wraps the keys +is how that secret is replaced ([personal data](../core/personal-data.md#rotating-the-secret-that-binds-them)). ``` bash bin/console beffroi:data-keys:rewrap --to=second --dry-run diff --git a/docs/symfony-bundle/key-management.md b/docs/symfony-bundle/key-management.md index 912299c..67e212e 100644 --- a/docs/symfony-bundle/key-management.md +++ b/docs/symfony-bundle/key-management.md @@ -66,7 +66,7 @@ nothing. A member listed under `retired` is read and never written to. |---|---| | The keys of the tenant, the ones that sign, the ones clients encrypt to, and the ones it keeps rather than publishing: the key messages are sealed to, the application server key of web push and the SAML keys | One file per key, `//.json`: the public half in the clear, the private half sealed. The directory is created `0700` and every file `0600` | | The pairwise salt of the tenant (OIDC Core §8.1), drawn at its first use and never again | `//pairwise.salt`, sealed | -| The data keys of the sealed columns, one per tenant and per family of columns | Rows of `beffroi_data_key`, wrapped by the master key of the tenant; each row names the client and the master key that wrapped it | +| The data keys of the sealed columns, one per tenant and per family of columns | Rows of `beffroi_data_key`, wrapped by the master key of the tenant; each row names the client and the master key that wrapped it, and is bound to its reference and its scope under a secret of `beffroi.keys.data_key_secrets`, which the database does not hold | | The secrets held for people: the secret of an authenticator application, the identifier and token of a backchannel request whose client is called back, the person a self-contained access token is about, an enrolment of an authenticator application in progress | Envelopes, in their column, in the token, or in the HTTP session of the browser | Every envelope is bound to its tenant and to its use: sealed for one tenant it does not open for another, and a From 35c62eadb148d8461cc2e96fbbcba0a722c9369e Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 26 Oct 2026 15:16:21 +0100 Subject: [PATCH 4/4] docs: what a secret of the data keys stops opening, and which sub is read for its client, as the code holds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read against the code by another hand: under another secret it is the values sealed under a data key that stop opening, and the lookups by a blind index with them, not the envelopes of the key management service; a row opens nothing when its key is not the one minted for its reference and its scope; changing APP_SECRET alone leaves the personal data unreadable rather than being impossible; RFC 9101 §10.5 is cited for the authorization request alone; and the sub read for its client is the one this provider gave that client. Read against beffroi-auth/beffroi 9597ebd3. --- docs/application/configuration.md | 2 +- docs/application/operating.md | 5 +++-- docs/core/personal-data.md | 11 ++++++----- docs/oidc/interactions.md | 7 ++++--- docs/reference/configuration.md | 2 +- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/application/configuration.md b/docs/application/configuration.md index 3529e7d..abf1b47 100644 --- a/docs/application/configuration.md +++ b/docs/application/configuration.md @@ -40,7 +40,7 @@ for a restart of the processes and not for a new image. | Variable | In `.env` | What it decides | |---|---|---| | `APP_ENV` | `dev` | The environment of the kernel. The production image sets `prod` | -| `APP_SECRET` | empty | The secret of the kernel, `framework.secret`. `.env.dev` and `.env.test` give one each, for their own environment, so a production environment names its own. It is also the secret every data key of personal data is bound to its row under, `beffroi.keys.data_key_secrets` naming no other: the store of the data keys refuses an empty one the first time it is used, and a new one replaces it only as [operating it](operating.md#data-keys) says | +| `APP_SECRET` | empty | The secret of the kernel, `framework.secret`. `.env.dev` and `.env.test` give one each, for their own environment, so a production environment names its own. It is also the secret every data key of personal data is bound to its row under, `beffroi.keys.data_key_secrets` naming no other: the store of the data keys refuses an empty one the first time it is used, and changing it alone leaves the personal data unreadable: [operating it](operating.md#data-keys) says how it is replaced | | `APP_SHARE_DIR` | `var/share` | The share directory of Symfony, one per environment beneath it, `var/share/prod` for a production kernel. It holds the filesystem pool of `cache.app`, and with it the [caches of the bundle](deployment.md#what-the-processes-share) | | `DEFAULT_URI` | `https://op.localhost` | The URL Symfony generates absolute URLs under outside a request, `framework.router.default_uri` | | `DEFAULT_LOCALE` | `en` | The language a page falls back to, among the two the application serves, `en` and `fr` | diff --git a/docs/application/operating.md b/docs/application/operating.md index c7a28db..eae4f09 100644 --- a/docs/application/operating.md +++ b/docs/application/operating.md @@ -92,7 +92,8 @@ The data keys are bound under `APP_SECRET`, which `beffroi.keys.data_key_secrets [personal data](../core/personal-data.md#rotating-the-secret-that-binds-them): `%kernel.secret%` first and the previous value second in `beffroi.keys.data_key_secrets`, given to every process; `beffroi:data-keys:rewrap` towards the client that wraps the keys, `op` unless they were moved; then the previous value dropped. Changed -alone, it leaves every sealed value unreadable. +alone, it leaves the personal data sealed in the database unreadable, and the lookups by a blind index with +them. ## The data downloaded @@ -141,7 +142,7 @@ One directory to back up, one key management service and one secret to keep. | The database | PostgreSQL | Everything the model writes, the personal data sealed in their columns, and the data keys wrapped by the master key | | The sealed keys | `var/keys` | One file per key of the tenant, and the pairwise salt of the tenant beside them, each sealed by the key management service | | The master key | The key management service | What opens all of the above | -| The secret of the kernel | `APP_SECRET`, in the environment of every process | What every data key is bound to its row under: under another secret, the database opens no sealed value | +| The secret of the kernel | `APP_SECRET`, in the environment of every process | What every data key is bound to its row under: under another secret, none of the personal data sealed in the database opens, and no lookup by a blind index works | No command restores a deployment: a restore is the database and `var/keys` put back together, under the same master key and the same `APP_SECRET`. The pairwise subjects of OpenID Connect and the persistent identifiers of SAML are computed from diff --git a/docs/core/personal-data.md b/docs/core/personal-data.md index 3a14e78..b83d43a 100644 --- a/docs/core/personal-data.md +++ b/docs/core/personal-data.md @@ -101,9 +101,10 @@ once per value. Nothing in the ordinary life of a tenant removes a data key: a n there, and a rewrap changes what protects a key and not the key. Each row is also bound to its reference and its scope: an HMAC-SHA256 of the two, under a key derived from the -data key and from a secret the database does not hold. A row edited, copied under another reference or scope, -or written into the table by whoever can write the database opens nothing, even when the key it carries is -wrapped under a master key the provider may use. The secrets are those of `beffroi.keys.data_key_secrets`, the +data key and from a secret the database does not hold. A key that is not the one minted for its reference and +its scope therefore opens nothing: a row whose key was replaced, a row copied under another reference or +scope, a row written into the table by whoever can write the database, even when the key it carries is wrapped +under a master key the provider may use. The secrets are those of `beffroi.keys.data_key_secrets`, the secret of the kernel unless the configuration names others, one list for the whole deployment: the first one binds a new row, and a row bound under any of them opens. A row bound under a secret that is no longer listed opens nothing, and neither does any value sealed under its key. @@ -161,8 +162,8 @@ beffroi: 3. Drop the old secret once the command has moved every key. The secret of the kernel is replaced the same way when the configuration names no secret of its own, -`%kernel.secret%` written first and its previous value second: changed alone, it leaves every sealed value of -the deployment unreadable. +`%kernel.secret%` written first and its previous value second: changed alone, it leaves every value sealed +under a data key unreadable, and no lookup by a blind index works. ## Destroying them diff --git a/docs/oidc/interactions.md b/docs/oidc/interactions.md index 5693453..fb0654d 100644 --- a/docs/oidc/interactions.md +++ b/docs/oidc/interactions.md @@ -53,9 +53,10 @@ the password, or the password followed by a code. A login of the person already proved to the session the browser holds; a login of somebody else ends that session first. The `sub` of an `id_token_hint` is the one the provider gave the client of the request, and it is read for -that client, as every `sub` a client presents is ([the way back](pairwise-subjects.md#the-way-back)): the -subject the account carries for a client of the `public` kind, the identifier of its sector for a -[pairwise](pairwise-subjects.md) one. A `sub` that names nobody for that client is a hint of another person. +that client, as every `sub` this provider gave a client is +([the way back](pairwise-subjects.md#the-way-back)): the subject the account carries for a client of the +`public` kind, the identifier of its sector for a [pairwise](pairwise-subjects.md) one. A `sub` that names +nobody for that client is a hint of another person. ### The class asked for diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 88fa358..4e3abf2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -254,7 +254,7 @@ refused when the provider first reads its clients, naming the client, the option | `require_pushed_authorization_requests` | boolean | `false` | The client starts an authorization request only by pushing it (RFC 9126 §6), and any other request of it is refused on a page before its redirect URI is read. The discovery document announces the requirement when every client of the tenant carries it | | `require_sender_constrained_tokens` | boolean | `false` | A token request of the client proves a certificate (RFC 8705 §3) or a key (RFC 9449 §5), or it is refused rather than answered with bearer tokens (FAPI 2.0 Security Profile §5.3.1) | | `tls_client_certificate_bound_access_tokens` | boolean | `false` | The tokens of the client are bound to the certificate it presents at the TLS handshake, whichever way it authenticated (RFC 8705 §3.4), and a token request that proves no certificate is refused. A client authenticating by certificate gets bound tokens either way | -| `require_signed_request_object` | boolean | `false` | A request of the client is read only from a request object it signed (RFC 9101 §10.5), wherever the object arrives: in the query, behind a `request_uri`, in a pushed request or in a backchannel authentication request. An unsigned authorization request is refused on a page before its redirect URI is read, an unsigned backchannel one with `invalid_request`. A declaration that names it beside `request_object_signing_alg: none` is refused | +| `require_signed_request_object` | boolean | `false` | A request of the client is read only from a request object it signed, wherever the object arrives: an authorization request in the query, behind a `request_uri` or in a pushed request (RFC 9101 §10.5), and a backchannel authentication request in its body. An unsigned authorization request is refused on a page before its redirect URI is read, an unsigned backchannel one with `invalid_request`. A declaration that names it beside `request_object_signing_alg: none` is refused | | `id_token_signed_response_alg` | string | the default algorithm, `RS256` | The JWS algorithm the ID tokens and the logout tokens of the client are signed with (OIDC Registration §2), among the wired ones: `RS256`, `PS256`, `ES256` and `EdDSA` in the bundle, the first being the default | | `id_token_encrypted_response_alg` | string | none | The JWE key management algorithm the signed ID token is encrypted to a key of the client with (OIDC Core §3.1.3.7), among the wired ones: `ECDH-ES+A256KW` and `RSA-OAEP-256` in the bundle. Named with `id_token_encrypted_response_enc` or not at all. Left out, the ID token is signed and not encrypted | | `id_token_encrypted_response_enc` | string | none | The JWE content encryption algorithm of that ID token, among the wired ones: `A256GCM` in the bundle. OIDC Registration §2 defaults it to an algorithm the bundle does not wire, so it is written whenever `id_token_encrypted_response_alg` is |