DatumHuedatumhue.com
Contents

Identity Provider

What it does

The identity provider connects your organization's sign-in services, Keycloak, Okta or any other OIDC service, to a deployment. It tells workstations which sign-in services they may offer, and publishes each service's signing keys to the aggregator, where every other provider reads them to verify who is signed in. See the Overview for how it fits with the other components.

In this manual, the identity provider is this program, and a sign-in service is the OIDC system that signs people's tokens.

Quick start

Describe the provider in the deployment description, deployment.kdl: the host it runs on and each sign-in service your people use.

kdl
identity-provider {
    host "providers-1"
    issuer "keycloak" {
        issuer-url "https://keycloak.example.com/realms/acme"
        client-id "datumhue-client"
        scopes "profile" "email" "groups"
    }
}

Make its files with the deployment tool, generate or add-provider. At each sign-in service, register the aggregator's URL followed by /oidc-callback.html as a redirect URI, and allow http://127.0.0.1:* for the workstation app; at a service that lists every redirect URI exactly, give the issuer a client-secret and register /oidc-native-callback.html instead.

On the host, install the program, then follow the README.md in the host's folder of the bundle, which installs the rest and starts it:

shell
sudo install -D -m 755 identity-provider-linux-x86_64 \
    /opt/datumhue/identity-provider

Its log names its public key, which matches the line for it in the bundle's pubkeys.txt, and then each announcement.

It logs identity_provider ready once every service's keys are published.

How it works

Sign-in services. Each issuer entry names one sign-in service under a short name, which people see when they sign in. The provider announces each one to the aggregator under a capability for identity/<name>, and workstations find the list there.

Signing keys. The provider fetches each service's signing keys over HTTPS from its OIDC discovery address, whose document must name the service's issuer-url exactly. It publishes them to the aggregator under a second capability, for the service's issuer URL, and logs that capability's namespace at start. Every provider that checks sign-ins reads the keys there, by the exact issuer URL of a person's token. The keys are published again every half of the shortest jwks-ttl; while a fetch fails, the keys published before stay valid until their jwks-ttl ends.

Token requests. A sign-in service with a client-secret issues tokens only to requests that carry it. Workstations and browsers send their sign-in and token-refresh requests for such a service to the provider, which adds the secret and passes them on over HTTPS. It passes on no other request, and never logs their contents.

Sessions. For a service with a client-secret, the provider keeps each sign-in's refresh token, and the workstation holds a handle in its place. A sign-in unused for idle ends, and so does a person's least recently used one when they sign in a 21st time. Signing out on a workstation ends its sign-in here, and datumhue logout --everywhere every sign-in of the person that this provider holds; upstream-revocation says what that does at the service. When the provider a workstation signed in through is gone for good, its people sign in again.

Without a client-secret, a workstation that signs out revokes its refresh token at the service, when the service's discovery document names a revocation address. Keycloak before 26.1 answers a revocation by ending every sign-in the person has with the client; 26.1 and later end only the revoked one.

Ending a person's sign-ins. When someone asks you to erase what the provider keeps about them, identity-provider sessions erase ends every sign-in of theirs that the provider holds, as datumhue logout --everywhere does, upstream-revocation included. Run it on the provider's host as the account the provider runs as, naming the admin socket that serve --admin-socket gives; without that setting no sessions command reaches the provider. Name the sign-in service with --issuer, and give the person's sub on standard input: the identifier datumhue login shows after "Signed in as". --dry-run lists the sign-ins it would end. When the command says that no sign-in held a token the service could revoke, or that the service confirmed fewer revocations than it was asked for, ask the person to remove the deployment's access in their account settings at the service.

shell
sudo -u datumhue /opt/datumhue/identity-provider sessions erase \
    --issuer google --dry-run \
    --admin-socket /var/lib/datumhue/identity-provider/admin.sock

Readiness. Once the provider has published every service's keys, it announces its sign-in services and logs identity_provider ready. Until then people see none of its services, not even one whose keys were published, and the log says why each attempt failed. A supervisor can wait for that line.

Command line

text
identity-provider <COMMAND>
CommandDescription
identity-provider serveStart the provider service.
identity-provider sessionsEnd an account's sign-in sessions through a running provider's admin socket.

identity-provider serve

text
identity-provider serve [OPTIONS]
FlagEnvDefaultDescription
--config <CONFIG>, -c./identity_provider.kdlPath to the configuration file
--aggregator-url <AGGREGATOR_URL>, -anoneOverride the aggregator-url from the configuration file
--admin-socket <ADMIN_SOCKET>DATUMHUE_ADMIN_SOCKETnoneLocal socket identity-provider sessions reaches this provider through; it answers only the account the provider runs as. Its folder is made, mode 0700, when missing; a folder another account owns, or others may write in, stops the start. Without it, no sessions command reaches this provider

identity-provider sessions erase

text
identity-provider sessions erase [OPTIONS] --issuer <ISSUER>
FlagEnvDefaultDescription
--issuer <ISSUER>requiredThe sign-in service the account signs in with, by its issuer name in the settings
--dry-runoffList the sessions that would end, and end none
--admin-socket <ADMIN_SOCKET>DATUMHUE_ADMIN_SOCKETnoneThe running provider's admin socket, the path its serve --admin-socket names

Configuration

The settings file is KDL: a provider block, then a trusted-issuers block with an issuer entry for each sign-in service.

kdl
provider {
    secret-key-file "/var/lib/datumhue/identity-provider/provider.key"
    capability-file "/var/lib/datumhue/identity-provider/provider.caps"
    aggregator-url "https://aggregator.example.com"
    relay-url "https://aggregator.example.com"
}

trusted-issuers {
    issuer "keycloak" {
        issuer-url "https://keycloak.example.com/realms/acme"
        client-id "datumhue-client"
        scopes "profile" "email" "groups"
        jwks-ttl "5m"
    }
    issuer "okta" {
        issuer-url "https://acme.okta.com/oauth2/default"
        client-id "datumhue"
        scopes "profile" "email"
    }
    issuer "entra" {
        issuer-url "https://login.microsoftonline.com/<tenant-id>/v2.0"
        client-id "<application-id>"
        scopes "profile" "email"
        person-claim "oid"
    }
}

Common provider settings

Every provider's settings block takes these keys beside its own. A relative path anywhere in the settings file resolves against the folder that holds the file, not the folder the provider starts in.

KeyRequiredDefaultDescription
aggregator-urlyesnoneThe aggregator's public HTTPS URL. --aggregator-url overrides it
relay-urlyesnoneThe aggregator's relay, at the same public HTTPS URL
discovery-urlnothe aggregator'sWhere the provider publishes its address and looks up others
announce-ttlno300How long each announcement stays valid: seconds, or a duration such as "5m", at least 2 seconds. The provider announces again after half of it
webrtc-bind-addrnononeA fixed UDP address for direct browser connections, such as "0.0.0.0:7844". Set it with webrtc-public-addr when the provider runs in a container or behind port forwarding
webrtc-public-addrnononeThe host:port browsers reach webrtc-bind-addr at. Every address it resolves to must route to that socket

The key and the capability file. The secret key is the provider's identity: the provider creates it at its first start when the file is missing, and logs its public key at every start. The capability file holds the capabilities that let the provider announce what it serves. The provider reads it again before every announcement and rewrites it as capabilities renew, so it refuses to start when it cannot write to the file's folder. See Provider authorization.

VariableDescription
DATUMHUE_KEYSTORE_PASSPHRASEEncrypts the secret key the first start creates. While the key is encrypted the variable must be set, and while it is plaintext it must be unset
DATUMHUE_CERT_PATHA PEM file of CA certificates to trust beside the public ones, for an aggregator whose certificate a private CA signed. Set, it must name a file of CA certificates only
RUST_LOGLog levels, info unless set

Identity provider settings

KeyRequiredDefaultDescription
secret-key-fileno./identity_provider.keyThe provider's secret key
capability-fileno./identity_provider.capabilitiesThe capabilities to announce each sign-in service and publish its keys

Sign-in services

KeyRequiredDefaultDescription
issuer "<name>"yesnoneOne sign-in service, by the short name people see and its capabilities use
issuer-urlyesnoneThe service's issuer URL, exactly as its tokens' iss claim gives it; a trailing / counts. An https URL of at most 2048 bytes with no query, fragment or user name, one entry per URL
client-idyesnoneThe client registered at the service for this deployment
client-secretnononeThe client's secret, for a service that asks for one when it issues tokens. The provider then makes those requests for workstations and browsers, which never see the secret
auth-param "<key>" "<value>"nononeOne more parameter for the service's sign-in page, such as auth-param "prompt" "consent"; one entry per parameter
audiencenothe client-idThe value tokens' aud claim must hold. Set it only when the service puts something else in its ID tokens' aud, and never to the id of an API, whose access tokens could then pass for sign-ins
scopesnononeScopes to ask for besides openid, which is always asked for. Ask for the scopes that carry the claims your rules use, such as groups
jwks-ttlno"5m"How long published keys stay valid, from a second to a day: seconds, or a duration such as "30s", "1h" or "24h"
person-claimno"sub"The claim that names the person in the service's ID tokens and access tokens alike, for a service whose sub differs between them. An egress mount that forwards access tokens takes one as the signed-in person's only when it carries the ID token's value of this claim. A claim many people share, or one a person or the service can change, such as tid or email, stops the provider at start
upstream-revocationno"none"With a client-secret only: what ending a sign-in does at the service. "token" revokes that sign-in's refresh token, for a service whose revocation ends one sign-in (Okta, Keycloak 26.1 and later); "grant" revokes once every sign-in of the person has ended, for a service whose revocation ends all of them (Google, Keycloak before 26.1); "none" asks nothing of the service

A shorter jwks-ttl spreads a key change at the service sooner, at the cost of fetching and publishing more often. Five minutes suits a service whose keys rarely change.

Microsoft Entra ID gives each application its own sub for one person, so its access tokens for an API registered as another application name the person by another sub than its ID tokens do. Its oid is the same in both: set person-claim "oid", and ask for the profile scope, without which Entra leaves oid out of ID tokens.

Sessions

A sessions block sets how the provider keeps the sign-ins of the services with a client-secret.

kdl
provider {
    secret-key-file "/var/lib/datumhue/identity-provider/provider.key"
    capability-file "/var/lib/datumhue/identity-provider/provider.caps"
    aggregator-url "https://aggregator.example.com"
    relay-url "https://aggregator.example.com"
}

trusted-issuers {
    issuer "google" {
        issuer-url "https://accounts.google.com"
        client-id "datumhue.apps.googleusercontent.com"
        client-secret "the-client-secret"
        upstream-revocation "grant"
    }
}

sessions {
    store "/var/lib/datumhue/identity-provider/sessions.redb"
    idle "30d"
    reuse-grace "60s"
}
KeyRequiredDefaultDescription
storenoidentity_provider.sessions.redb beside secret-key-fileThe file that holds the sign-ins. A relative path counts from the settings file
idleno"30d"How long an unused sign-in lasts, at least a second
reuse-graceno"60s"How long after a refresh the handle it replaced still refreshes once, for a workstation that lost the answer; up to 5 minutes

Registering the client

At each sign-in service, register the aggregator's public URL followed by /oidc-callback.html as a redirect URI of the client, for browsers. The workstation app's redirect URI depends on the service's entry:

  • Without client-secret, the workstation app signs in through a loopback redirect URI on a port it picks: allow loopback redirect URIs on any port, http://127.0.0.1:*.
  • With client-secret, the workstation app signs in through the aggregator: register the aggregator's public URL followed by /oidc-native-callback.html as well. Use this for a service that lists every redirect URI exactly, such as Google.

Access rules

The identity provider decides no access itself; the other providers decide it with their access rules, from the claims of a person's ID token. Each rule that reads a sign-in sits in a block naming its service's issuer URL: issuer "<issuer-url>" { … }, with the URL exactly as this provider's issuer-url spells it. A claim works in a rule when:

  • the service that signed the token is an issuer here, its issuer-url exactly the token's iss, and this provider holds the capability to publish that service's keys;
  • the rule sits in the block of that same URL: a token from any other issuer gets nothing the block grants;
  • the service puts the claim in the ID token: ask for the scope that carries it in scopes, and set the service up to add the claim;
  • for email, the same token says email_verified is true, and for phone_number, phone_number_verified is true. The email and phone scopes carry them. Microsoft Entra ID does not send email_verified: match oid, sub or groups there.

Network, state and dependencies

Network surface

DirectionWhat
from workstationsThe list of sign-in services
from workstationsTheir token requests and sign-outs, for a service with a client-secret
to the aggregatorEach service's keys, every half of the shortest jwks-ttl
to the aggregatorAnnouncing each service, every half announce-ttl
to each sign-in serviceIts discovery address and its keys, over HTTPS
to each sign-in serviceWorkstations' token requests, with the client-secret
to each sign-in serviceRevocations, as its upstream-revocation says

The provider listens on no TCP port; workstations reach it through the aggregator. It takes 256 connections at once; past that a connection is turned away. The admin socket, when serve --admin-socket names one, is a local socket on the provider's host.

Persistent state

PathHolds
secret-key-fileThe provider's identity. A lost key needs new capabilities
capability-fileIts capabilities, which it renews itself
sessions storeThe sign-ins of the services with a client-secret. Only with such a service

The session store and the key file together expose the current and recently ended sign-ins: keep both, and their backups, as private as the key file. Restoring an older store ends each sign-in that refreshed since it was saved, and losing the store or the key ends every sign-in: its people sign in again.

External dependencies

  • The aggregator, for announcing and publishing keys.
  • Each sign-in service, reachable over HTTPS at its issuer-url. Its certificate must come from a public CA, or from one in DATUMHUE_CERT_PATH.

Running as a service

systemd

The deployment tool writes a unit like this one for every provider it sets up. To run the provider under systemd by hand, install the program as /opt/datumhue/identity-provider, its settings as /etc/datumhue/identity-provider.kdl, and give the datumhue user the state folder /var/lib/datumhue/identity-provider/.

/etc/systemd/system/datumhue-identity-provider.service:

ini
[Unit]
Description=DatumHue Identity Provider
After=network.target

[Service]
Type=simple
User=datumhue
Group=datumhue
WorkingDirectory=/var/lib/datumhue/identity-provider
ExecStart=/opt/datumhue/identity-provider serve \
    --config /etc/datumhue/identity-provider.kdl
Environment=DATUMHUE_CERT_PATH=/etc/datumhue/ca-cert.pem
EnvironmentFile=/etc/datumhue/keystore.env
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/datumhue/identity-provider

[Install]
WantedBy=multi-user.target
  • The unit keeps /etc/datumhue read-only, and a relative path in the settings file resolves there. Point secret-key-file, capability-file and every other file the provider writes into /var/lib/datumhue/identity-provider/.
  • keystore.env holds DATUMHUE_KEYSTORE_PASSPHRASE=...; keep it mode 600. Drop the EnvironmentFile= line for a plaintext key.
  • Drop the DATUMHUE_CERT_PATH line when the aggregator's certificate comes from a public CA.
  • On the aggregator's host, add datumhue-aggregator.service to After=.
shell
sudo systemctl daemon-reload
sudo systemctl enable --now datumhue-identity-provider
journalctl -u datumhue-identity-provider -f

systemctl stop stops the provider the way Ctrl+C does: it stops announcing and gives its provider seat back, so the next start takes a seat at once.

Set DATUMHUE_CERT_PATH when the aggregator or a sign-in service uses a certificate from a private CA; one file may hold several CA certificates.

Docker

To run the provider in a container, build an image from this Dockerfile, in the folder that holds the program:

dockerfile
FROM debian:bookworm-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*
COPY identity-provider-linux-x86_64 /opt/datumhue/identity-provider
ENTRYPOINT ["/opt/datumhue/identity-provider"]
CMD ["serve", "--config", "/etc/datumhue/identity-provider.kdl"]

Run it as the datumhue user that owns the state folder, with the settings folder read-only:

shell
docker build -t datumhue/identity-provider .
state=/var/lib/datumhue/identity-provider
docker run -d --name datumhue-identity-provider --restart always \
    --user "$(id -u datumhue):$(id -g datumhue)" \
    --env-file /etc/datumhue/keystore.env \
    -e DATUMHUE_CERT_PATH=/etc/datumhue/ca-cert.pem \
    -v /etc/datumhue:/etc/datumhue:ro -v "$state:$state" \
    datumhue/identity-provider

The docker-compose.yml the deployment tool writes runs images by these names, with the same folders.

Security notes

  • The write capability is the right to publish keys. Only a provider that holds it for the namespace of the service's issuer URL publishes that service's keys, whatever name its settings give the service. Several providers may publish for one service; readers take the keys of all of them. A revocation of the provider's key in that namespace stops its publishing, and other providers stop accepting the keys it published within a minute.
  • A key change at the service reaches every provider within half the shortest jwks-ttl plus a minute. Shorten jwks-ttl when that matters.
  • The audience and the kind of token are always checked. A token whose aud does not hold the audience, or the client-id when no audience is set, is refused. So is a token that marks itself as an access token by its typ, token_use, scope or scp.
  • A copied sign-in ends. With a client-secret, a workstation's handle changes at every refresh, and one used again ends its sign-in. A service without a client-secret leaves its refresh tokens on the workstations: turn on refresh-token rotation with reuse detection there (Keycloak: "Revoke Refresh Token" on, "Max reuse" 0), and a copied one ends the same way.
  • The admin socket ends sign-ins. Whoever can run commands as the provider's account can end anyone's sign-ins through it; it answers that account alone.
  • A client-secret is a password. Keep a settings file that holds one at mode 600, owned by the datumhue user.
  • Protect the key. The deployment tool encrypts it with your passphrase; a key the provider makes at its first start is plaintext unless DATUMHUE_KEYSTORE_PASSPHRASE is set. The provider refuses a key file that others can read.

Troubleshooting

Messages. The provider writes its log to standard output; under systemd, read it with journalctl -u and the unit's name.

The aggregator is refused as untrusted. It serves the certificate it made itself: point DATUMHUE_CERT_PATH at its tls/ca-cert.pem.

provider lease acquisition failed. With QuotaExhausted, every provider seat of the license is taken: stop a provider, or extend the license.

The seat or announcements are refused. capability-file holds no current capability for this key: make one with datumhue-deploy reissue-capabilities.

The provider stops at start, naming relay-url. Set relay-url to the aggregator's public HTTPS URL.

The provider refuses its key file. A key file that others can read is refused: chmod 600 it. DATUMHUE_KEYSTORE_PASSPHRASE must be set for an encrypted key and unset for a plaintext one; the message says which the key is.

The provider cannot write its capability file. Give the provider's user write access to the folder that holds the file; it rewrites the file as capabilities renew.

The provider never logs identity_provider ready. One service whose keys fail to publish holds back every service of the provider. Read the failed to publish JWKS for lines:

  • no capability to publish the keys of <url>, or holds no current capability to publish these keys: the capability file lacks a current capability for that issuer URL's namespace, which the line names. A capability for identity/<name> announces the service but publishes no keys; the deployment operator issues the missing one with datumhue-deploy reissue-capabilities.
  • a revocation withdraws this provider's right: the deployment operator revoked this provider's key for that service with datumhue-deploy revoke; datumhue-deploy lift restores it.
  • the discovery document names issuer: the service calls itself by another issuer URL, which its tokens carry too. Set issuer-url to the one the line quotes, a trailing / included; the changed URL needs a capability of its own, as above.
  • holds no signing key: the service's key address lists no RSA or EC key for signatures.
  • URL scheme is not allowed: the service's discovery document gives an http address, or one of its addresses redirects to one. The provider reaches a sign-in service over HTTPS only.
  • the aggregator refused the issuer's settings: the line names the setting it refused.
  • the key set is not, or the aggregator refused the sign-in service's key set: the service's key address answered with something other than a key set.
  • make a publish of: the service publishes more keys than an aggregator takes in one publish, 64 KiB.
  • A discovery or fetch error: the service cannot be reached, or issuer-url is wrong.

People see no sign-in services. The log shows announcer running and announced issuer once the provider announces. Without announcer running, the provider is not ready yet: see the entry above on identity_provider ready. With it but without announced issuer, the capability file lacks a current capability to announce identity/<name>.

The sign-in page reports an invalid redirect URI. Register the redirect URIs that Registering the client lists for the service's entry: a service that lists every redirect URI exactly needs a client-secret, since the workstation app uses /oidc-native-callback.html only then.

Other providers refuse signed-in people. Check that the provider published the keys: from its host, curl -I <issuer-url>/.well-known/openid-configuration must succeed.

sessions erase gets no answer. "connecting to the admin socket" means the path is not the one serve --admin-socket names, or the provider runs without it. "closed the connection unanswered" means the command ran as another account than the provider's: run it as that account. When the provider stops at start over the admin socket, its folder belongs to another account or others may write in it: make it mode 700.

Tokens fail on the audience. The token's aud lacks the client-id. Add the client-id as an audience at the service, or set audience to what the service puts in aud.

A claim is missing from tokens. Ask for the scope that carries it in scopes, and set the service up to add the claim to the ID token.

DATUMHUE_CERT_PATH is refused at start. It names a server certificate. Point it at the CA certificate, such as ca-cert.pem.

datumhue.com

Victor Mono is licensed under the SIL Open Font License 1.1
Copyright (c) 2024, Rune Bjørnerås (https://github.com/rubjo)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL


-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
    ↑↓ selectEnter openTab filter

    Settings

    Theme
    Text
    Print

    Keyboard shortcuts

    / or Ctrl KSearch
    ↑ ↓ EnterPick a result
    TabNext result filter
    j kNext or previous entry
    [ ]Previous or next page
    yCopy a link to the current entry
    ?These shortcuts
    EscClose

    On this page