Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/application/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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` |
Expand Down
15 changes: 12 additions & 3 deletions docs/application/operating.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,21 @@ 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=<client>` | 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=<client>` | 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 the personal data sealed in the database unreadable, and the lookups by a blind index with
them.

## The data downloaded

| Command | When it is run by hand | Its timed step |
Expand Down Expand Up @@ -127,16 +135,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, 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. 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.
1 change: 1 addition & 0 deletions docs/core/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions docs/core/personal-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ 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 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.

``` bash
bin/console beffroi:data-keys:list
```
Expand Down Expand Up @@ -134,6 +143,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 value sealed
under a data key unreadable, and no lookup by a blind index works.

## Destroying them

``` bash
Expand Down
2 changes: 2 additions & 0 deletions docs/oidc/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/oidc/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -52,9 +52,11 @@ 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` 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

Expand Down
10 changes: 5 additions & 5 deletions docs/oidc/logout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions docs/oidc/pairwise-subjects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions docs/oidc/request-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading