# Shared authentication and repository permissions

For every service and administration command and option, see the
[auth CLI reference](/docs/reference/cli/scribe-auth.md). Client login and trust options are in the
[client CLI reference](/docs/reference/cli/scribe.md). This guide explains identities, permissions,
TLS trust, and credential lifecycles. `scribe-ctl` sets all of this up for you
([tutorial 2: add authentication](/docs/tutorials/02-add-auth.md)); to do it by hand,
follow [bare metal, stage 2](/docs/manual/bare-metal.md#2-add-authentication) or
[Docker Compose, stage 2](/docs/manual/docker-compose.md#2-add-authentication).

Every command-line tool except `scribe-runner` supports global `--json`; see [JSON output for automation](/docs/json-output.md) for results, errors, and exit codes. For `scribe-runner`, `--log-format json` formats its logs only.

Scribe can use one `scribe-auth` service for several independent storage
servers. Auth owns users, credentials, server identities, grants, and the two
certificate authorities that certify credential holders. Storage servers own
repository data and enforce permissions on each operation. Auth does not
receive repository contents or check whether a repository exists.

Authenticated storage connections use protocol **4 over TLS 1.3**; the auth
service uses its own binary protocol, SAU2, over TLS 1.3. Every Scribe TLS
endpoint (auth, storage, gateway, and the trust-discovery handshake) offers and
accepts only the hybrid post-quantum key exchange `X25519MLKEM768`; a peer
without it cannot connect. An authenticated listener never negotiates
plaintext or an older protocol. Callers prove who they are with a client
certificate in the TLS handshake; no password-derived or bearer secret travels
to a storage server or gateway. For implementation details, see
[architecture](/docs/architecture.md#shared-authentication) and the
[wire specification](/docs/wire-format.md).

Every certificate authority and server key Scribe creates is **ML-DSA-65**
(FIPS 204), a post-quantum signature. That covers the server CAs, auth's
credential and access CAs, and every server's TLS key. Recovering one of those
private keys from its public certificate would let an attacker impersonate
a server or issue certificates, and ML-DSA resists that with a quantum
computer as well. Device keys stay ECDSA P-256 (see
[below](/docs/auth.md#identity-device-keys-and-certificates)). Their public keys are hard
to reach: TLS 1.3 encrypts client certificates under the hybrid key exchange,
and auth stores only a digest of each key. A forged device signature would
also need a quantum computer during the live handshake. A CA created before ML-DSA, or one an operator made with another
algorithm, keeps working; rotating it gives an ML-DSA-65 successor.

## Credentials and roles

Every account has an immutable random 128-bit ID, an immutable username, enabled
status, a separate auth-administrator flag, and a kind: `person` or `service`.
A person is a user; a [service account](/docs/auth.md#service-accounts) is a machine identity
that authenticates only with credential files. Regular users log in with a
username and password to obtain a revocable session. Passwords use
Argon2id v19 (19,456 KiB, two iterations, one lane, 32-byte output) with a
fresh 16-byte OS-random salt and a server-only 32-byte pepper. The result is stored
as a PHC string. Passwords contain
12–1024 UTF-8 bytes; there is no whitespace normalization. Password files
may end with one LF or CRLF, which is removed when read.

A *credential* is a device key its holder generated, registered at auth and
certified by it (see [identity, device keys and certificates](/docs/auth.md#identity-device-keys-and-certificates)).
Credentials are individually revocable and expire. Disabling a user revokes
every credential it holds, so enabling the account again revives none of them.
The initial `admin` identity is reserved and cannot be disabled; its bootstrap
credential can be revoked after another administrator credential is established.

Usernames can be short names (1–64 ASCII letters, digits, `.`, `_`, `-`)
or email-style/AD user principal names such as `alice.smith@company.example`
and `alice+build@company.example` (up to 254 ASCII bytes). Principals have
exactly one `@` with nonempty parts; the local part allows letters, digits
and `.!#$%&'*+-=?^_` plus backtick, braces, pipe and tilde. The suffix allows
letters, digits, `.`, `_`, and `-`. Whitespace, control characters, path
separators and quoted addresses are rejected. Names retain their exact
spelling and are case-sensitive; use the same spelling in all commands.
A user principal name (UPN) is accepted as a Scribe identifier; Scribe does not
connect to Active Directory or verify a mailbox. Certificates never carry a
username; they name the immutable account ID.

The administration examples use the `auth` shell function defined in
[authentication tutorial](/docs/tutorials/02-add-auth.md#4-connect-the-auth-administration-cli-and-add-users):

```sh
auth users add alice.smith@company.example
auth users password alice.smith@company.example --password-file ./secrets/alice.password
auth grants set alice.smith@company.example studio/REPOSITORY_ID --role writer
```

Each registered storage-server identity belongs to exactly one namespace and
has an immutable random ID that auth assigns when it is added. Give each server
its own revocable server credential, even when several servers share a
namespace. A server credential can validate callers and read decisions only for
that namespace. It cannot list users, issue credentials, or edit grants.
(Server credentials identify storage servers; service-account credentials
identify a [service account](/docs/auth.md#service-accounts) and are used like a user's
credential.) User credentials are not bound to a namespace: one login is valid
wherever the user holds grants, which is what lets a single
[gateway](/docs/gateway-usage.md#namespaces) front several namespaces and address
their repositories as `<namespace>/<name>`.

Repository keys are strings: **`<namespace>/<immutable-repository-id>`**.
For example `studio/6f76a25d5d0840c299c23771657b5b34`. The server derives this
mapping, never the client. Display-name changes do not change permissions.
Auth accepts grants for any well-formed string before a repository exists;
there is no repository registry in auth. Two servers hosting independent
repositories named `game` have different IDs and independent permissions.
Restoring a backup preserves the repository identity and its grants; it is
recovery of that repository, not a new independent clone.

| Role | Allowed |
|---|---|
| Reader | Repository discovery and metadata, history, object presence/download, workspace registration, branch and merge reads |
| Writer | Reader plus object uploads, submits, branch create/rename, own workspace locks |
| Maintainer | Writer plus other-owner workspace listing/retirement and generation-checked forced unlock and changes to the repository `kind`, the ordered `locks.rules`, and the mount topology in `.scribe/config.toml` on `main` (including merges and removal) |
| Namespace reader | Reader on every repository in that namespace, including repositories created later, and the namespace-wide reads: the storage inventory a gateway or controller polls |
| Namespace administrator | All repository roles in that namespace, the namespace-wide reads, and repository creation |
| Delegate | A service account (a gateway) that may present users' access certificates to the storage servers of that namespace; see [gateways and delegation](/docs/auth.md#gateways-and-delegation) |
| Auth administrator | Central user/credential/server/grant administration; no implicit storage access |
| Service account | At most Writer, through its grants and its credential's ceiling; never Maintainer, namespace or auth administration, or sign-in |

A grant gives one account one role on one repository key. There are no groups
and no path-level permissions: a role applies to the whole repository. Grant
each account separately on each repository, or grant a namespace-wide role.

A namespace reader grant (`namespace-readers set USER NAMESPACE`) gives Reader
on every repository key in the namespace, including repositories created after
the grant, without a grant per repository. Any account kind can hold it. The
effective role is the stronger of the direct grant and the namespace reader
grant, so a direct writer grant still writes; a namespace reader grant alone
never writes, creates repositories, or administers.

Granting namespace administration is an explicit, separately audited command.
Repository maintainers do not gain permission to manage central users/grants.
Writers may change profiles on main and submit lock-rule proposals on other branches.
On main, only changes to the decoded ordered lock-rule strings require a Maintainer;
formatting, comments, and profile-only edits do not. Deleting nonempty rules,
their section, or the config file counts as a lock-policy change. A merge whose
`.scribe/config.toml` entry keeps main's configuration changes nothing on main
and needs no Maintainer. Only the accepted
main-branch copy controls repository-wide locking. Policy queries require Reader;
lock acquisition requires Writer and workspace ownership. Main-policy write
permission is checked through `AuthProvider` before entering publication gates.
Private repository access without a matching grant is denied. Public visibility
adds anonymous read access; writes still require an authenticated grant. See
[public repositories](/docs/public-repositories.md) for policy administration, downloads,
and revocation semantics. Repository discovery filters out
inaccessible repositories. Operation outcomes are visible only to their
recorded user, who must still have repository read access; another user's
operation ID is answered exactly like an unknown one. Permission to edit a
change note follows the owner recorded by the submit operation that accepted
the change, not the author text shown in history.

## Identity, device keys and certificates

Every credential holder generates its own **device key**: a `scribe login` on
a workstation, a web session, a CI machine, a runner, a storage server, a
gateway, and an administrator each hold a different one. Keys are ECDSA P-256
(recorded as algorithm `p256` with every credential). The private key never
leaves its holder. Auth stores only the **key ID**, the full BLAKE3 digest of
the public key's SubjectPublicKeyInfo, and registers each public key at most
once.

Buffers that hold a private key are wiped when they are dropped: device keys,
the PEM and credential-file text written or read for them, the CA keys auth and
`scribe-cert` load, and a server's TLS key file. Scribe decodes its own PKCS#8
key files itself, so no parser keeps an unwiped copy. TLS signs with the key
object AWS-LC builds from them, and AWS-LC wipes that object when it frees it.
Two cases keep an unwiped copy. A server key in another encoding (SEC1, PKCS#1,
or a key next to other PEM sections) goes through the general PEM reader. Auth's
CA keys also stay in its database file and that file's page cache.

Auth runs two private client certificate authorities with ML-DSA-65 keys,
created by `init` (or `migrate`) and stored in the auth database. It issues two kinds of X.509
certificate for the same registered key:

| Certificate | Issued by | Lifetime | Accepted by |
|---|---|---|---|
| Credential certificate | credential CA | the credential's: a session's 90 days by default, a machine credential's until its expiry; the bootstrap administrator's never expires | auth only |
| Access certificate | access CA | one hour, never beyond the credential's expiry | storage servers and gateways only |

A certificate from one CA is refused by the other kind of service, so nothing a
storage server or gateway receives can act at auth. Clients never create
certificates; auth issues all of them for a public key the client supplies. A
holder presents its credential certificate to auth and asks it for access
certificates (`access-certificate`, which writes nothing at auth), then
presents the access certificate to storage servers and gateways. Storage
servers themselves use only their credential certificate, towards auth.

Each certificate names exactly one SPIFFE identity in a URI subject
alternative name and has an empty subject:

```text
spiffe://<trust-domain>/user/<user-id>/credential/<credential-id>
spiffe://<trust-domain>/server/<server-id>/credential/<credential-id>
```

People and service accounts share the `user` ID space; storage servers use
`server`. IDs are 32 lowercase hexadecimal characters; a username never
appears. The trust domain is chosen once with `scribe-auth init --trust-domain`
(default `scribe`; 1–64 lowercase letters, digits, `.`, `-`, `_`).
Certificates certify the holder's P-256 key under the issuing CA's ML-DSA-65
signature, and carry a random serial, `CA:false`, the `digitalSignature` key
usage and the `clientAuth` extended key usage. A certificate a Scribe peer
sends or stores is at most 16,384 bytes of DER.

The certificate is not the whole check. Every verifier also asks auth whether
the certificate's credential ID is live and whether the certificate's public
key is the one registered for it, and whether the account or server is
enabled. Revocation therefore takes effect within the 30-second authorization
lease whatever the certificate's lifetime, and a leaked CA key alone certifies
nothing usable. TLS 1.3 signs the handshake with the device key, so the proof
is bound to that connection and cannot be replayed elsewhere.

Clocks are tolerated rather than trusted. Auth backdates every certificate by
10 minutes and verifiers accept 5 minutes of skew past its validity. Clients
renew an access certificate once half its lifetime has passed as measured by
their own clock between receipt and now (a clock that jumps backwards also
renews), so a drifting wall clock never causes hourly failures. Long-running
services (storage servers and gateways) refresh auth's access CAs every five
minutes and refuse presented certificates until the first refresh succeeds.

### Rotating the internal CAs

Both CAs are valid for 20 years and may be replaced without users noticing:
auth trusts every CA row it holds (`trust-bundle` lists them all), and a CA
added later starts signing 15 minutes after it was added, by when every
running verifier has refreshed its trust. The oldest CA of a purpose is retired
only once nothing it signed can still be presented (or, for the credential CA
with `--revoke-held`, together with revoking what still holds one of its
certificates; its holders sign in again), and a retirement moves the
[revision](/docs/auth.md#rollback-protection) like any other security-reducing change.
`scribe-auth authorities list|add|retire` does this offline, and
`scribe-ctl renew --rotate-ca credential|access` in a managed cluster; see
[auth's internal CAs](/docs/manual/certificates.md#auths-internal-cas).

### Credential files

Administrators, machines, storage servers, gateways and `scribe-ctl` keep a
credential in one text file, the *credential bundle*:

```text
scribe-credential v1
auth tls://auth.example:7443
-----BEGIN PRIVATE KEY-----        the device key (PKCS#8)
-----BEGIN CERTIFICATE-----        the credential certificate
-----BEGIN CERTIFICATE-----        the CA of the auth service's TLS certificate
```

The file says where its holder reaches auth, holds the device key and the
credential certificate auth issued for it, and the CA that verifies the auth
service. Parsing is strict: the two header lines, then exactly those three
PEM sections in that order. `scribe-auth init`, `migrate`, `restore`,
`admin-credential issue` and `credentials create` write these files: always
new files, never overwritten, mode 0600 on Unix, and never inside any `.scribe`
directory. On Unix, every reader refuses a credential file that grants any
permission to group or other (`… is accessible to other accounts (mode 644);
run chmod 600 on it`) or whose directory other accounts can write, unless that
directory is sticky (`… is writable by other accounts …; restrict it with chmod
go-w`). The same rule applies to the pepper, the revision witness, session
device keys and the runner's token and device key. On Windows the same rule
reads the access list: a file that allows any account other than its owner,
the reading account, SYSTEM and Administrators more than reading its
attributes is refused (`… is accessible to another account (S-1-…)`), as is
one whose directory lets such an account delete its entries or change its
access list. Scribe writes these files with a protected list allowing only
the writing account, SYSTEM and Administrators.

| Holder | How it reads its credential file |
|---|---|
| Auth administrator | `scribe-auth --admin-credential FILE` (also `scribe-server users`/`permissions`) |
| Machine or CI job | `scribe --credential-file FILE` or `SCRIBE_CREDENTIAL_FILE` |
| Storage server | `[auth] credential` / `--auth-credential` |
| Gateway | `[tls] credential` |
| `scribe-ctl` | `[auth] admin_credential`, `[[storage]] credential` and `monitor_credential` |

A credential file is as powerful as the account it names until it expires or is
revoked. Anyone who can read it can act as its holder; revoke it with
`credentials revoke` when it may have been copied.

## Service accounts

A service account is an account of kind `service` for build farms, CI, and
other machines. It has the same username rules and ID space as users, so its
name appears in grants, audit rows, and submit attribution. It has no password
and never signs in; it authenticates only with credential files that an auth
administrator issues:

```sh
auth users add build-farm --service
auth namespace-readers set build-farm studio
auth credentials create build-farm --service --name ci-read --out ./secrets/build-farm.credential --days 90
```

Point the machine at the file with `scribe --credential-file FILE` or
`SCRIBE_CREDENTIAL_FILE`. A plain `--service` credential is a `service-reader`
credential and never writes. `--service --write` issues a `service-writer`
credential, which writes only where the account also holds a writer grant:

```sh
auth grants set build-farm studio/REPOSITORY_ID --role writer
auth credentials create build-farm --service --write --name ci-write --out ./secrets/build-farm-write.credential --days 30
```

Service accounts are deliberately limited:

- no password: `users password` on one is refused, and login is refused with
  the same answer as a wrong password;
- never an auth administrator (`users add --service --administrator` is
  refused) and never a namespace administrator;
- grants are at most `writer`; a `maintainer` grant is refused;
- credentials expire within 365 days and are revocable one by one; disabling
  the account revokes all of them. `credentials list --user NAME` lists them;
- a service-account credential cannot call `identity`, create automation
  credentials, log out, or perform any auth administration.

Namespace reader grants are the usual way to give a service account read
access to a whole namespace, including repositories created later.

Every machine identity is a service account with the narrowest grant its job
needs, never an administrator: the web services read with a namespace reader
grant, `scribe-ctl` takes inventory with one, and a gateway holds a namespace
reader grant and a delegate grant (see
[namespace-wide operations](/docs/auth.md#namespace-wide-operations) and
[gateways and delegation](/docs/auth.md#gateways-and-delegation)).

## Gateways and delegation

A [gateway](/docs/gateway-usage.md) routes unchanged clients to storage servers.
Clients connect to it exactly as they connect to a storage server, with their
own access certificate; the gateway verifies that certificate against auth's
access CAs. Towards each storage server the gateway connects with its own
access certificate and says, in the protocol 4 hello, how to read the caller:

- *delegated*, for user traffic: the hello carries the user's access
  certificate, which the storage server verifies against the access CAs; auth
  then checks that the user's credential is live and that the gateway's account
  is a delegate in that server's namespace;
- *direct*, for the gateway's own inventory polling, under its own identity;
- *anonymous*, for anonymous readers of public repositories.

The gateway's identity is a service account. For every namespace it fronts it
needs a namespace reader grant (`namespace-readers set`, to poll inventory) and
a delegate grant (`delegates set`, to act for users), and it holds a
service-account credential; [bare metal, stage 3](/docs/manual/bare-metal.md#3-add-a-gateway)
provisions it. `delegates set` accepts only service accounts. Storage servers decide
everything about delegated traffic as if the user had connected directly: the
user's grants, the user's attribution, and limits charged to the user rather
than to the gateway. A delegated decision's lease is also bounded by the
gateway credential's expiry. A compromised gateway can act only for users
whose access certificate it currently holds, at most for that certificate's
remaining hour, and only in the namespaces where it is a delegate. It never
receives anything that works at auth or at another gateway. `scribe-ctl setup`
provisions this account for a managed gateway; see the
[cluster guide](/docs/cluster-usage.md#optional-managed-gateway).

## Namespace-wide operations

A few storage operations concern a namespace as a whole rather than one
repository. The storage server gates each through one rule, and every such
operation names which it needs:

| Operation | Needs |
|---|---|
| Instance inventory (`instance_status`): every repository's ID, name and size, and the data root's used and free bytes | Reading the namespace: a namespace reader grant or namespace administration |
| Repository creation (`repo_create`) | Administering the namespace: namespace administration |

A service account reads a namespace only through its own namespace reader
grant; it is never a namespace administrator.

## Setup

`scribe-ctl` provisions auth for a cluster it manages: `scribe-ctl add auth`
puts it in front of existing storage ([tutorial 2](/docs/tutorials/02-add-auth.md)),
and `scribe-ctl setup --variant auth` builds a new cluster with it ([set up a
new cluster](/docs/cluster-usage.md#set-up-a-new-cluster)). The sections below
explain the rules behind each step; for every option, use the [auth CLI
reference](/docs/reference/cli/scribe-auth.md).

### Set up auth by hand

The steps are in [bare metal, stage 2](/docs/manual/bare-metal.md#2-add-authentication)
and, for containers, [Docker Compose, stage 2](/docs/manual/docker-compose.md#2-add-authentication).
In outline: two server-certificate CAs and a certificate for each service;
`scribe-auth init`, which writes the pepper, the revision witness and the
bootstrap administrator credential; `scribe-auth serve`; for each storage
server an identity (`servers add`) and its own credential (`credentials
create --server`); `scribe-server auth-init` and the storage `[auth]` table;
then people and grants.

### Generate a private CA and server certificates

Certificate generation is built into Scribe; no external certificate tool is
required. These are the services' own TLS server certificates, separate from
the two client CAs auth keeps in its database. Because that database holds
the client CAs' private keys, `init` creates the auth data directory with mode
0700, and every auth command refuses (`restrict it with chmod 700 DIR`) a
data directory other Unix accounts can enter or list; `scribe-ctl` plans and
`up` check the same before they start auth. The auth service, storage
server, and cluster controller use the same certificate validation rules.
Existing CA certificates are checked for signing permissions, validity,
key pairing, and self-signature before issuing a server certificate. Generated
server certificates are verified through rustls for every requested DNS/IP name.

Both service CLIs provide the same helper; all flags are in the [certificate
reference](/docs/reference/cli/scribe-auth.md#certs). Use a separate CA for the auth
service's certificate, as `scribe-ctl` does (`[certificates] auth_ca_dir`): a
storage or gateway key issued by the other CA can then never pass as auth. The
[certificates guide](/docs/manual/certificates.md#the-four-certificate-authorities)
shows both server CAs and auth's two internal CAs side by side.

Use the real DNS name clients connect to. Repeat `--dns`
and `--ip` for additional identities, up to 64 total. DNS names must be exact
ASCII hostnames (use punycode for international names); wildcards and URLs are
not accepted. IPv4 and IPv6 addresses belong in `--ip`.

`init-ca` produces `ca.pem` and `ca-key.pem`. The CA can sign leaf
certificates and exactly one intermediate below it (path length 1): the
handover certificate of a CA rotation. Each `issue` directory contains
`server.pem`, `server-key.pem`, and a copy of `ca.pem`. The CA and server keys
are ML-DSA-65; `issue` also accepts a CA key in another algorithm
(ECDSA, Ed25519, RSA), and the server key it creates is ML-DSA-65 regardless.
Issued certificates permit server authentication only. The helper verifies the
certificate chain and key pair before reporting success. It does not create
users or credentials.

Every output directory must be **new**, with an existing parent. Existing
directories, files, and symlinks are refused; there is no overwrite flag.
Unix directories are created with mode 0700 and files with mode 0600. On
Windows the directory gets a protected access list allowing only the
creating account, SYSTEM and Administrators, and the files inherit it. Private keys are unencrypted so services
can start unattended. Keep the CA signing key (`ca-key.pem`) offline and backed up; give
each server only its own leaf key/certificate and the public CA certificate.
The server sends its public CA during TLS setup: keep the generated `ca.pem`
beside `server.pem`, or supply a complete certificate chain in the configured
certificate PEM. Neither private key is sent. Generation also prints the
full CA fingerprint for administrators to share with users.

CA lifetime defaults to 3650 days; server certificates default to 365 days.
`--days` accepts 1–3650, and the issuer must remain valid for the requested
server lifetime. Renewing a certificate from the same CA needs no new trust on
any client; replacing the CA does, unless it is rotated with a handover
([certificates and credentials](/docs/manual/certificates.md)). These certificates
are a private studio PKI, not publicly trusted certificates.

If generation fails, the new directory may contain incomplete output. The
helper reports failure and retains it for inspection; do not deploy that
directory. Correct the cause and use a new output directory. No repository or
auth data root is read or changed by `certs`.

### Bootstrap credentials and the storage binding

`init` writes the bootstrap administrator credential, the pepper and the
revision witness to the files it is given. The administrator credential
is an operator credential, not a default password or a storage server
credential. Only `init` issues a nonexpiring bootstrap credential. Every later
administrator, server, and service-account credential expires.
`credentials create` generates the device key on the administrator's machine
and writes it durably beside the pending request before transmitting only the
public key; the credential file is completed after auth answers. An interrupted
run is resolved by repeating the same command, which certifies the same key.

`auth-init` runs while storage is offline. It marks the root with the required
feature `auth-ownership-v1` and creates ownership state. A marked root or one
containing `auth-state.redb` refuses plaintext startup. Namespace changes require
an explicit migration; changing a configuration string cannot silently remap grants.
Storage requires all four `[auth]` settings together (namespace, credential file,
TLS certificate and key); the
[server configuration](/docs/server-usage.md#configuration-file) describes their TOML form.
The storage server learns the auth endpoint and auth's CA from its credential
file, verifies clients against the access CAs it fetches from auth, and asks
auth about each caller with its own credential certificate.

For auth admission limits and required flags, see [serve](/docs/reference/cli/scribe-auth.md#serve)
and [service admission limits](/docs/auth.md#service-admission-limits).
TLS handshakes and incomplete frames do not occupy storage workers. Response
writes and worker admission have a 10-second timeout. Shutdown closes idle
connections; after 30 seconds it interrupts remaining network I/O and waits for
started storage jobs.

### Service admission limits

Every storage server authorizes each request through the auth service, so a
peer that can merely reach the auth port must not be able to stall it. The
service therefore bounds what an unauthenticated connection and a login can
hold:

| Limit | Value | Why |
| --- | --- | --- |
| Handshake and first request | 3 seconds, one deadline | Until a complete request frame arrives the peer is anonymous; a silent or trickling connection releases its slot quickly. |
| Idle connection | closed after 60 seconds | A connection carries sequential requests, so storage servers keep a small pool instead of a handshake per decision. |
| Incomplete connections per source | 16 (`--max-pending-per-source`) | One address cannot fill the connection slots. IPv6 sources are counted per /64. An idle connection between requests counts as incomplete again. |
| Full server | oldest incomplete connection closed | Anonymous or idle connections never keep a newcomer, such as a storage server, out; they can delay it by at most one handshake. A newcomer is refused only when every slot carries a request in progress. |
| Concurrent password hashes | 2, with 32 waiting | Logins wait for a hasher without occupying a storage worker or a thread, leaving workers for authorization. A full queue answers `auth_unavailable` ("login busy; retry later"). |
| Login attempts per source and username | 10 per minute | A guessing source is throttled for that account without locking the account out for anyone else. |
| Login attempts per source | 60 per minute | One source cannot spray passwords across many accounts. |
| Login attempts per username, all sources | 100 per minute | A ceiling against distributed guessing that no single source can reach on its own. A source that signed in successfully as that user within the last 30 days is exempt, so guessing elsewhere cannot lock the owner out. |

Refused attempts answer `auth_unavailable` ("too many login attempts; retry
later") before any password is hashed, and each window resets a minute after
its first attempt. Attempt tables are bounded; when one is full, the oldest
window is forgotten rather than a new source or username refused. Connection
limits count per address as the auth service sees it, so a NAT or a proxy
counts as one source: the web interface signs users in from its own address
and should rate-limit its callers itself. Raise `--max-pending-per-source` when
several storage servers share an address. Refused and evicted connections are
logged at debug level as `connection.refused` and `connection.evicted`. A
failed sign-in logs `login.failed`, a refusal by the per-username ceiling logs
`login.throttled` (warning), and an issued session logs `login.succeeded`; each
names the username, never the password.

Diagnostic selection, daily files, retention, and shutdown flushing are described
once in the [logging guide](/docs/logging.md). Auth lifecycle events include
`auth.listening`, `auth.stopped`, and `auth.failed`. These logs are independent of
durable administrative audit records.

### Password pepper ownership

`init --pepper-out FILE` creates a random 32-byte pepper as 64 lowercase hex
characters in a new protected file. The file must be outside the auth data root
and any `.scribe` directory (the whole namespace, at any level), including
through symlinks. `serve --pepper-file FILE`
requires that same key on every start and refuses a missing, malformed, or wrong
key. The database stores only its full BLAKE3 fingerprint. Clients and storage
servers never receive the pepper; it is not embedded in PHC strings or logs.
Unix files use mode 0600; on Windows they allow only the writing account,
SYSTEM and Administrators.

Keep a protected backup of the pepper separately from auth database backups.
A database-only leak then does not allow password guesses without the pepper.
Losing it prevents password verification: restore the matching key. Replacing
it is not a rotation mechanism; there is no automatic rotation or migration.
A compromised pepper requires a planned credential reset and reprovisioning.
An unsupported auth format is refused. Preserve the data root and its matching
pepper when investigating a startup failure.

### Upgrading the auth data directory

Normal startup never converts auth data. A directory written by an older build
is refused with ``auth schema 3 is not supported by this build (supports 4); run
`scribe-auth migrate` explicitly``. To upgrade:

```sh
# stop scribe-auth serve, then back up the whole auth directory
scribe-auth migrate --data-dir ./auth-data --dry-run
scribe-auth migrate --data-dir ./auth-data --revision-file ./secrets/auth.revision \
  --admin-credential-out ./secrets/admin.credential \
  --auth tls://auth.example:7443 --auth-ca ./pki/ca/ca.pem
```

`migrate` takes the directory's exclusive lock, so it fails while the service
runs. `--dry-run` validates every record it reads and reports what would
change, writing nothing. A real run always names the revision witness
(`--revision-file`), which a migration rewrites; `--admin-credential-out`,
`--auth` and `--auth-ca` are needed only from schema 3, and a run from schema 3
without them is refused. `scribe-ctl upgrade` runs auth migrations itself
(dry run, backup, migration, rollback with the witness), except this one: it
refuses schema 3 to 4, which replaces every credential and is done by hand as
below. Only schema 3 upgrades to schema 4. The upgrade keeps
every account, password, grant, namespace role, visibility policy, storage
server (each is assigned an immutable random ID) and audit row. Bearer tokens
cannot become key credentials, so every token, session and automation token is
removed: people sign in again and every machine, storage server and gateway is
issued a new credential file. The upgrade creates the trust domain
(`--trust-domain`, default `scribe`), both client CAs and the rollback state,
and writes a new credential for the bootstrap `admin` account (valid 3650 days)
to `--admin-credential-out`, using `--auth` and `--auth-ca` as the endpoint
written into it. A malformed user or server record aborts the run before any
write.

The credential file is written and flushed first, then every converted record
commits in one durable transaction, then the revision witness is written, and
the schema marker is replaced last by an atomic rename. A rerun after the
records committed only finishes the witness and marker. A rerun that finds the
credential file but no converted records refuses and names the file: remove it
and rerun. A directory that is already current reports that there is nothing to
do. The pepper file is not read. Start the upgraded service with
`serve --revision-file` naming the new witness.

### User login and session lifetime

How people log in, log out and check their session is in [log
in](/docs/client-usage.md#log-in); this section describes what happens underneath.
`scribe whoami` is local status only; it does not verify server-side
revocation. Its `--json` form includes anonymous and credential-file overrides.

The administrator provisions an initial password using `users password` and
delivers it securely. That command also resets a password and invalidates all
previous user sessions and automation credentials by changing the user's
password generation; auth refuses a change that would keep the old generation.
It never changes the user's immutable ID or grants. Password hashes are excluded
from user listings, audit rows, and logs. The administrator sends the password
only through verified TLS; the auth server hashes it using Argon2's secret-input
parameter and its pepper, outside the database write transaction. The
administrator's local journal stores a BLAKE3 password commitment keyed by a
key derived from the administrator's device key; it never leaves that machine.
The server's durable operation intent is keyed separately by the pepper. Neither
journal contains plaintext or an unkeyed password verifier. An unresolved retry
checks the original outcome first; if unknown, it requires the unchanged password
file. Administrator credentials remain independent of user sessions.
A password-issued session never invokes auth administration, even an auth
administrator's. Administration takes an administrator credential: a
long-lived one from `credentials create` (for automation, scripts and
`scribe-ctl`), or a short-lived one from
[administrator sign-in](/docs/auth.md#administrator-sign-in).

Configure the lifetime of **new** sessions with
[`scribe-auth serve --session-ttl-days`](/docs/reference/cli/scribe-auth.md#serve).

`--session-ttl-days` defaults to 90 and accepts 1–3650. The auth server chooses
and durably records an absolute expiry. Clients cannot request a longer TTL.
Restarting the service or changing this flag does not extend existing sessions.
There is no automatic renewal: the client asks the user to log in again after
expiry. Credential revocation, a disabled user, or a password reset also prevents
access (subject to the authorization-cache bound of 30 seconds).

`scribe login` saves the session for the selected `--server` (or workspace
server) under the per-user config root's `sessions/` directory. Without
`--password-file`, a terminal is required; the prompt is on stderr, so stdout
remains command output (including with `--json`). Passwords are never
command-line arguments, echoed, logged, or saved in session state.
On macOS, interactive entry also acquires Secure Event Input for the duration
of the password read and releases it before authentication. A failure to enable
it aborts the prompt. Normal completion, input errors, and cancellation release
Scribe's Secure Input assertion; panic unwinding also restores it. Other
applications' Secure Input assertions are preserved. Windows and Linux use native echo-disabled input;
there is no portable terminal API providing equivalent keyboard interception
protection. For SSH, keyboard protection belongs on the local computer.

Login generates a new device key and a random credential ID on the local
machine, flushes both with the pending operation, and only then sends the
username, the public key and the password. Successful password verification
registers the key as a session credential with a server-selected expiry and
answers its credential certificate. A retry of a login whose answer was lost
asks `login-outcome` first; it returns the original certificate and expiry and
never issues a second session. The password is not part of persisted
intent/outcome data. Logging in again for the same server revokes the previous
session first; if that revocation cannot be completed, the new login still
proceeds, a warning (`session.revoke_failed`) is logged, and the old session
stays valid until it expires or is revoked.

A session directory holds the device key (`key.pem`), the credential
certificate (`credential.pem`), the session's identity and expiry, the cached
access certificate, and, when `login --auth-ca` was given, a copy of that CA
(`auth-ca.pem`), never a path to it. Commands renew the access certificate from
auth with the credential certificate when it is missing or past half its
lifetime; parallel commands wait while one renews. Saved sessions are scoped to
the normalized storage hostname and port, and retain the auth endpoint needed
for renewal and logout. An explicit `--credential-file` / `SCRIBE_CREDENTIAL_FILE`
takes precedence over the saved session.

The password file is not copied into session state. New directories/files use 0700/0600 on Unix
and a protected access list for this account, SYSTEM and Administrators on Windows. A
`--config-dir` / `SCRIBE_CONFIG_DIR` that already exists keeps its own permissions, so it must
be user-private (the default under `%LOCALAPPDATA%` is).
No credential is saved anywhere under `.scribe/`, at any level.
Pass `--auth-ca FILE` for explicit auth-service trust, or approve its CA using
the same first-use trust mechanism as storage connections. Passwords are sent
only after TLS verification. Auth and storage endpoints have separate trust.

`scribe logout` revokes the current session at auth before removing the saved session.
It presents the session's own credential certificate and uses the CA copy saved
at login; pass `--auth-ca FILE` to logout only to use a different file. A session
whose credential already expired has nothing left to revoke and is removed
locally without contacting auth. A connection failure keeps the pending
operation; rerun the same command. Auth answers a logout of an already revoked
credential as successful, so a retry always completes. A trust or configuration
failure before the request leaves no pending operation. Commands refuse to use a
session with a pending logout. Sessions on other machines remain valid until
revoked, reset, disabled, or expired. Without a saved session, logout
reports `not logged in at SERVER` and changes nothing.

A login whose answer never arrived is kept as an unfinished login for that
username and auth endpoint until a retry resolves it or `scribe login
--discard` drops it; every refusal caused by an unfinished login names that
command. Discarding never revokes anything: a session the server may have issued for the
lost answer stays unused until it expires.

Login hashing runs on an admitted blocking worker, outside the publication gate
and database write transaction. Unknown users undergo a dummy Argon2id
verification and receive the same error as an incorrect password. Password
state and enabled status are rechecked after hashing under the publication gate
so a concurrent reset/disable cannot race session issuance. Each successful
login is recorded in the audit log.

### Administrator sign-in

Administrator sign-in (`admin-login`) is login's exchange with a different
result: an auth administrator signs in with username and password, from
[`scribe login --admin --out FILE`](/docs/client-usage.md#sign-in-as-administrator)
or the [web administration page](/docs/web-interface.md), and receives a short-lived
administrator credential instead of a session, the way `sudo` grants
administration for a while after a password. No administrator has to fetch a
credential file from the auth host first.

The password is checked exactly as for login: the same Argon2id and pepper, the
same hashing admission and per-source and per-username attempt ceilings, and
the same `invalid username or password` for an unknown, disabled or service
account and for a wrong password. Only after the password is proven does an
enabled person who is not an auth administrator hear `auth administrator
required` (`FORBIDDEN`), and nothing is issued. For an administrator, auth
registers the client-generated public key as an administrator credential of
kind `user`, the kind `credentials create` issues for an administrator, labeled
by the client (`admin-sign-in` from the CLI), with an expiry auth chooses:
[`scribe-auth serve --admin-session-hours`](/docs/reference/cli/scribe-auth.md#serve),
1–24 hours, 8 by default; clients cannot ask for longer. The answer is its
credential certificate, and the audit log records it as `admin-login` with the
username.

The credential is an ordinary administrator credential in every other way: it
runs any administrative request, `credentials list` shows it, `credentials
revoke` revokes it (it may revoke itself), and it counts toward the account's
256 live credentials. It also carries the password generation it proved, like a
session, so a password reset invalidates it, and disabling the account revokes
it with the rest. Because it lapses within hours and the password can sign in
again, it is never kept as an administrator's last credential and never counts
as the one that remains when another is revoked or a credential CA is retired.

The client persists the new device key and a random operation ID before sending
(`scribe login --admin` under `admin-sign-in/` in the per-user configuration
root). Administrator sign-in shares login's operation space: a retry with the
same ID returns the original certificate and never a second credential, an ID
reused for another key or for a session login is refused as
`OPERATION_INTENT_MISMATCH`, and `admin-login-outcome` resolves a lost answer
without issuing anything. The web API holds its key in memory for the one
request instead and sends nothing to the browser but the finished credential;
a lost answer there leaves at most one unused credential that lapses by itself.

### Remembered client trust

Clients trust each server's CA once, by comparing its fingerprint or with a CA
file, and keep that trust per host name and port outside every workspace; how
to approve, inspect and replace it is in [trust a
server](/docs/client-usage.md#trust-a-server). The rules that make it safe:

- The first connection is a TLS-only discovery handshake that sends no Scribe
  frame and no credential. After approval is saved, the client reconnects and
  verifies the host name, validity, chain and handshake signature with that CA
  before it presents a certificate or sends a password.
- A fingerprint is the full BLAKE3 digest of the CA certificate's DER bytes,
  written `BLAKE3:…`. It is public, so a CI variable can carry it
  (`--trust-fingerprint`). Scripts and `--json` never prompt.
- Records hold public CA certificates only. A corrupt record fails closed and is
  never silently replaced. Auth and storage endpoints have separate trust, and a
  credential file's auth CA is authoritative for its holder, so storage
  servers, gateways, administrators and machines reach auth without remembered
  trust.

#### CA rotation and handover

A saved record can hold several CAs for one server. A rotation introduces the
new CA with a *handover* (cross) certificate: the new CA's name and public key,
marked as a CA and signed by the old CA. When a verified chain passes through
such a certificate issued by a CA the client already trusts for that server,
the client saves the new CA beside the old one without asking: nothing is
believed that the trusted CA did not vouch for. The first connection that
verifies through the new CA but no longer through the old one drops the old
CA, so a retired (possibly compromised) CA stops being trusted. Any other change
is refused before a credential is presented, and saved trust is left unchanged
until the user confirms the new fingerprint with `scribe trust replace`
([when a server's CA changes](/docs/client-usage.md#when-a-servers-ca-changes)).

`scribe-ctl renew --rotate-ca storage|auth` ([rotate a
CA](/docs/cluster-usage.md#rotate-a-ca)) moves every server of the CA to the new one
in one run, so every server behind an endpoint presents the handover before a
client can drop the old CA; before the retirement each server presents
`[certificate, handover certificate]`, after it `[certificate, new CA]`, so the
fingerprint clients then see is the new CA's. The CAs' path length that lets
strict validators accept that chain is explained in
[the four CAs](/docs/manual/certificates.md#the-four-certificate-authorities).

The storage executable also provides [`users`](/docs/reference/cli/scribe-server.md#users)
and [`permissions`](/docs/reference/cli/scribe-server.md#permissions) using the same
administrator credential file. They are conveniences for auth administration,
independent of the daemon's server credential.

## Rollback protection

A copy of an auth data directory taken before a revocation still lists the
revoked credential as live. Bringing such a copy back by copying files would
silently revive it. Auth therefore keeps rollback state, and both auth and every
storage server refuse to go backwards:

- Every auth database has an **incarnation**, a random 128-bit ID created by
  `init`, `migrate` and `restore`, and a **revision** counter. The revision
  moves in the same write transaction as every applied administrative change
  (including credential revocation, a disabled user, a password reset, a revoked
  or downgraded grant, namespace role or delegate, visibility changes and a
  disabled server) and every logout. Every SAU2 answer names both.
- `serve --revision-file FILE` names the **revision witness**, a small file
  holding `<incarnation> <revision>`. `init` creates it; `migrate` and `restore`
  write it. The service rewrites it atomically after every change that moves
  the revision. Keep it, like the pepper, outside the auth data directory and
  out of that directory's backups: a backup must not carry its own witness.
  Startup refuses a database behind its witness (`auth database is at revision
  R but its witness records W: it was restored from an older copy, which would
  revive revoked credentials; run `scribe-auth restore` instead`) and a witness
  of another incarnation. A database ahead of its witness (the service stopped
  between committing and rewriting it) simply rewrites the witness.
- Every storage server durably records the newest incarnation and revision it
  has seen in its `auth-state.redb` before acting on a newer answer, and refuses
  every answer from a lower revision of the same incarnation with
  `auth_unavailable`: "the auth service is behind a revision this server
  already saw: its data was restored from an older copy, which would revive
  revoked credentials; restore it with `scribe-auth restore`". It logs
  `auth.rollback_detected`. A new incarnation is accepted and logged as
  `auth.incarnation_changed`; the incarnation it replaced is retired, and any
  later answer from a retired incarnation, at whatever revision, is refused the
  same way (the pre-restore copy has come back). The most recent 256 retired
  incarnations are kept.

The only way to bring an older copy back is
[`scribe-auth restore`](/docs/reference/cli/scribe-auth.md#restore), which starts a
new incarnation, moves the revision past both the backup and the witness, and
revokes every credential issued before it; see
[auth backup and restore](/docs/migration-backup-restore.md#auth-backup-and-restore).

## Administration and retries

For step-by-step replacement and lost-credential procedures, see
[certificates and credentials](/docs/manual/certificates.md).

`scribe-auth access` shows who can do what in one report, for a single storage
server or a whole cluster alike: every account with its kind (person or
service) and state, and each thing it may do, one row each: auth
administration, namespace administration, reading or delegation, repository
roles, live credentials and signed-in sessions. `--user NAME` narrows it to one
account. It is built from `users list`, `grants list`, `namespace-admins list`,
`namespace-readers list`, `delegates list` and `credentials list`, read with the
administrator credential, and changes nothing; see the
[CLI reference](/docs/reference/cli/scribe-auth.md#access) for its columns.

`users enable/disable`, `servers enable/disable`, `credentials revoke ID`,
`grants revoke USER REPOSITORY`, `namespace-readers set/revoke`,
`namespace-admins set/revoke`, and `delegates set/revoke` provide the
corresponding lifecycle operations. `users add --administrator` creates another
central administrator; `users add --service` creates a service account.
`credentials list` shows credential IDs, kinds, subjects, labels, key IDs and
expiry and creation times, never keys; `credentials list --user NAME` shows one
account's sessions and credentials from a per-account index rather than a scan.
Revoked credentials stay listed as disabled until a day after their expiry,
when the expiry sweep removes them.
Disabling a server blocks its credentials while it is disabled; enabling it
again makes them usable. Disabling a user revokes its credentials permanently.

Two limits keep administration and each account bounded. `credentials revoke`
refuses the last live (enabled, unexpired) administrator credential of an
enabled auth administrator with `the last administrator credential cannot be
revoked; issue another first`: sessions cannot administer, so issue a
replacement with `credentials create` before revoking the old one, or disable
the account instead. Credentials from administrator sign-in are never guarded
this way and never count as the replacement. Each account holds at most 256 live credentials (sessions,
automation, administrator and service-account credentials together); a new one
beyond that is refused with `LIMIT_EXCEEDED` until unused ones are revoked or
expire. Revoked and expired credentials do not count. The auth service also
enforces credential lifetimes itself: administrator and server credentials at
most 3650 days from issuance, service-account and automation credentials at
most 365; only the bootstrap credential from `init` never expires. Expired
credentials are removed by the running service itself: a bounded sweep every 15
minutes deletes credentials that expired more than a day ago, with their key
and index rows (the grace keeps a slightly late client hearing "expired", not
"invalid"), and logs `credentials.pruned`. There is no cleanup command to schedule or
forget; audit rows and operation outcomes are never pruned.

### Offline administrator recovery

When every administrator credential is lost or expired, `scribe-auth
admin-credential issue` issues a short-lived one directly into the stopped auth
data directory for an existing, enabled person administrator, under the
directory's lock and in one durable transaction with its audit row
(`admin-credential-issue-offline`, labelled `offline-recovery`). It never
creates an administrator or changes an account. The procedure is in [no
administrator credential left](/docs/manual/certificates.md#no-administrator-credential-left).

Lists return at most 256 rows. Their first column is the pagination cursor;
pass the last row's key as `--after KEY` to continue. `audit --after KEY`
lists audit rows with timestamp, immutable actor ID, command, operation ID,
status, and the request's arguments. Arguments never include a password; a
public key is shown as `key:<key ID>`. Besides every administrative request
(applied or rejected), the audit log records each login, logout, automation
credential creation and derivation, offline administrator issuance and restore.
Exact request fingerprints and outcomes are kept in the operation table.

Administrative mutations persist their operation ID and exact request, and for
`credentials create` the new device key, in the state directory before
transmission: `auth-admin` under the Scribe configuration root (the same root
`scribe --config-dir` / `SCRIBE_CONFIG_DIR` selects), or `--state-dir`. The
directory is created private and may not be inside `.scribe`. Unknown outcomes
retain the pending files. Repeating the same command resends the same operation
after querying `operation_outcome`; auth returns its durably recorded outcome.
`operations outcome OPERATION_ID` also queries it explicitly. Reusing an ID with
another actor or intent is refused; another administrator's operation ID is
answered like an unknown one. Confirmed outcomes remove the local pending
request. Local administrative invocations use an OS lock to avoid racing
credential files or pending requests.

## Sessions, revocation, and failures

Storage caches each positive or negative authorization decision per
credential and repository for at most **30 seconds**, capped by the caller's
credential expiry, a delegating gateway's credential expiry, and the server
credential's expiry. Cache expiry starts before sending the auth request. No
remote auth I/O occurs inside repository publication gates or redb write
transactions. Cache size is bounded to 256 entries per connection, within the
configured storage connection cap.

New sessions validate online. During an auth outage, existing requests can
use unexpired leases. Expired leases return `auth_unavailable`; no cached
permission is extended on failure. Disabling a user/server, revoking a
credential, or changing a grant affects new operation admissions within 30
seconds, whatever the lifetime of the certificates involved.
Already admitted requests may finish, including a durable submit or streamed
transfer. This does not promise instantaneous cancellation of in-flight work.
Auth has one process and one database owner; multiple replicas/automatic
failover are not implemented. Back up the complete auth directory while the
service is stopped, keeping the pepper, the revision witness and credential
files separately.

Before any backend work, a storage server or gateway decides from the verified
certificate how much the caller may hold: a source address may hold only a few
connections that have not finished their handshake (the oldest is closed for a
newcomer when the table is full), one principal may hold at most
`max_connections_per_principal` connections, and all of one principal's request
bodies together may reserve at most a quarter of the listener's request-body
budget. The principal is the user ID, also for traffic a gateway delegates.

Each secure control session returns a random 256-bit binding secret. Up to
four additional data connections must present the same credentials, certified
for the same keys, prove the binding secret, and reference a live control
session on that server. Closing the control connection stops further requests
on its data connections. The inactivity timeout applies to the session as a
whole, so a control connection is not closed for silence while one of its data
connections is transferring.

Protocol 4 uploads have a separate admission response before raw bytes:
a reader cannot send an asset upload and force the server to consume it.
All object payloads pass through TLS; the plaintext sendfile path is used
only by unauthenticated TCP2 connections.

## Ownership and existing data

Storage keeps a separately versioned `auth-state.redb` (schema 1) in its data
root, protected by the same root OS lock. Workspace ownership and operation
owner/intent claims commit with immediate durability before the corresponding
repository mutation. A crash may leave an unused claim, but it never accepts
history. Repeated claims must match the same immutable user and intent.
Accepted-history durability and existing operation outcomes are unchanged. The
same file records the newest auth incarnation and revision the server has seen
(see [rollback protection](/docs/auth.md#rollback-protection)).

Both layers claim the same normalized request: the principal's display name
replaces the client's author before the operation claim is written, and
attribution is not part of the operation fingerprint. Renaming an account at
the auth service therefore never turns the retry of a submit whose reply was
lost into `operation_intent_mismatch`; it still resolves to the original
outcome.

Workspace registration is a Reader operation that writes one durable row per
workspace ID, so each account may register at most 256 workspaces per
repository (`[limits].max_workspaces_per_principal`). Registering an ID the account
already owns is idempotent and never consumes another. Because an ownership
claim is permanent, an account may also make at most 64 new workspace claims
per hour on one server. Both refusals are `limit_exceeded`.

The authenticated user supplies submit and lock attribution. A client
`--author` cannot impersonate another user. Workspace IDs and operation IDs
are never proof of ownership. Existing workspaces from unauthenticated use
have no trusted owner and cannot be adopted by first use; create a fresh
workspace and preserve/reapply local edits explicitly. Existing history
remains readable under repository grants.

Storage backup/restore includes `auth-state.redb`. Do not remove it to bypass
authentication or copy only repository metadata when recovering secured
working-copy/operation ownership.

Test coverage for all of the above is listed in [testing.md](/docs/testing.md).

For TOML-based local cluster startup, pepper provisioning, and idempotent user
bootstrap, see [the cluster guide](/docs/cluster-usage.md).

## Developer reference: auth protocol and storage

SAU2 frames are `"SAU2"`, a little-endian `u32` payload length (at most 1 MiB),
and the payload. A request is a 16-byte operation ID, a `u16` count of
`str16` arguments (the verb first; at most 130 strings of at most 32,768 bytes,
since certificates travel as PEM text), and a `u8` flag followed by one
`str16` secret. The secret is the password of `login` / `login-outcome` and
`admin-login` / `admin-login-outcome` (1–1024 bytes) or the new password of `users-password` (12–1024 bytes); any other verb
carrying one, or these verbs without one, is `malformed`. A successful reply is
`u16 0`, the 16-byte auth incarnation, a `u64` revision, and at most 256 rows of
strings; a failure is a `u16` code and a message. The caller's identity comes
only from the TLS client certificate: its SPIFFE ID and the digest of its public
key, checked against the credential rows. A connection may carry any number of
sequential requests. The full verb table is in the
[wire specification](/docs/wire-format.md); the records are in the
[on-disk format](/docs/on-disk-format.md).

Login arguments are `[login, username, credential_id, public_key_hex, label]`
with a nonzero persisted operation ID; the public key is the device key's
SubjectPublicKeyInfo DER in lowercase hex. A successful login or known outcome
returns `[["ok", expiry_unix_ms, credential_certificate_pem]]`; an unknown
outcome returns no rows. The login intent hashes canonical `encode_intent`
bytes with a zero operation ID and the four arguments after the verb; no
password bytes are hashed into it. `admin-login` / `admin-login-outcome` take
the same arguments and answer the same rows; their intent hashes the string
`admin-login` followed by those four arguments, and the recorded outcome shares
login's `sessions/OPERATION` key, so one ID never serves both. `logout` /
`session-outcome` take only the verb and act on the credential that presented
the certificate: `[["ok"]]`, and
an already revoked, expired or pruned credential also answers `ok` without
writing. `users-password` arguments are `[users-password, username,
random_generation_id]`, followed by the new password as the secret. The
durable intent uses canonical `encode_intent` with a zero operation ID and all
three arguments, followed by password bytes, keyed using the pepper and context
`Scribe auth password operation intent v1`. The administrator's local
commitment uses a key derived from the device key with context
`Scribe auth admin password commitment v2`. Both keys use BLAKE3's
`derive_key`. Password changes use the durable administrative-outcome path;
replay cannot replace the original password.

The `SCRIBE_AUTH` marker is
`scribe-auth\nschema=4\nprotocol=2\npassword=argon2id-pepper-v1\n`. Any other
schema is refused at open; only `scribe-auth migrate` converts schema 3. Its
crash windows (`auth-migrate-bundle-durable`, `auth-migrate-records-durable`,
`auth-migrate-marker-staged`) are exercised through the `fault-injection`
feature and `SCRIBE_FAULT`, as is the window of offline administrator issuance
(`auth-admin-credential-secret-durable`: credential file durable, credential not
yet committed).

`auth_v1` stores rows using the single-row SAU2 result encoding. The account,
grant and policy rows are:

| Key | Row |
|---|---|
| `meta/pepper-id` | full BLAKE3 fingerprint of the raw 32-byte pepper |
| `meta/trust-domain`, `meta/incarnation`, `meta/revision` | SPIFFE trust domain; rollback state |
| `ca/credential/NNNN`, `ca/access/NNNN` | CA certificate PEM and PKCS#8 key; the highest number signs, all are trusted |
| `users/USERNAME` | immutable ID, username, enabled, administrator, kind (`person` or `service`) |
| `user-ids/ID` | username |
| `passwords/USERNAME` | peppered Argon2id PHC string, random 128-bit generation as lowercase hex |
| `servers/NAME`, `server-ids/ID` | immutable ID, name, enabled, namespace; name by ID |
| `credentials/CRED` | kind, subject, expiry Unix ms, enabled, label, key ID, algorithm, creation Unix ms, parent, password generation |
| `credential-keys/KEY_ID` | credential ID (a key is registered once) |
| `credential-certs/CRED` | the credential certificate, replayed by outcome queries |
| `user-credentials/USERNAME/CRED`, `server-credentials/NAME/CRED` | creation Unix ms (per-account and per-server index) |
| `meta/audit-sequence`, `audit/SEQUENCE` (20 digits, zero-padded) | Unix ms, actor user ID, command, operation ID, `ok` or `rejected`, arguments |
| `grants/NAMESPACE/REPOSITORY/USER_ID` | role |
| `namespace-admins/NAMESPACE/USER_ID`, `namespace-readers/NAMESPACE/USER_ID`, `delegates/NAMESPACE/USER_ID` | username |

User rows decode with exactly five fields, strict `true`/`false` flags, and a
known kind; a `service` row with the administrator flag set is corrupt. Each
credential kind requires its subject's kind: `user`, `session`, and
`automation*` belong to persons, `service-reader`/`service-writer` to service
accounts, `server` to a registered server, and a mismatch is refused as
`credential does not match its account`. Credential rows decode with exactly ten
fields, a known kind, the `p256` algorithm, a canonical key ID, a parent only on
derived automation credentials, a password generation always on sessions and
automation credentials, optionally on `user` credentials (exactly those
administrator sign-in issued), and never on service or server credentials. Anything else fails the request (and the expiry
sweep) as corrupt instead of being skipped or read as a default.

Visibility policy is stored as `visibility/<namespace>/<repository-id>` with a
canonical auth row `["private" | "public", decimal_revision]`. Missing means private
at revision zero. `visibility-get` is an administrator read. `visibility-set`
arguments are `[command, repository, visibility, expected_revision]` and use the
ordinary durable administrator intent/outcome/audit transaction.

`authorize` arguments are `[authorize, credential_id, key_id, repository,
delegate_credential_id, delegate_key_id]`, the delegate pair empty when the
caller connected directly; it returns `[principal_id, name, role,
namespace_admin, lease_ms, public_read, namespace_reader]`, and a row of any
other length is refused as malformed. The caller's credential must be live and
registered for that key; a delegate must be a live service-account credential
whose account holds `delegates/NAMESPACE/USER_ID` for the server's namespace.
`namespace_reader` says the credential reads the whole namespace, whatever key
was asked: a `namespace-readers` row, or, for a person's credential, namespace
administration. A service account's credential gets it only from its own
`namespace-readers` row. For a nonempty key, the account's live role is the
stronger of its direct grant and Reader when `namespace-readers/NAMESPACE/USER_ID`
exists; the credential's ceiling then applies: sessions, administrator
credentials, and `automation-admin` keep it (and namespace administration);
`automation`/`automation-write`/`automation-maintain` cap it as described below
with no namespace administration; `service-reader` caps it at reader and
`service-writer` at writer, never with namespace administration, even if a
namespace-admins row exists. `authorize-batch` reports read access from the same
decision.

Administrative argument vectors for accounts: `users-add` is `[command,
username, id, administrator, kind]`; `users-list` rows are `[users/NAME, id,
name, enabled, administrator, kind]`; `credentials-add` is `[command, kind,
subject, credential_id, public_key_hex, expiry_ms, label]` with kind `user`,
`server`, `service-reader`, or `service-writer`, and returns the credential
certificate; `credentials-certificate` returns it again for a credential ID;
`servers-add` is `[command, name, namespace, id]`; `namespace-readers-set` /
`-revoke` and `delegates-set` / `-revoke` are `[command, username, namespace]`;
`namespace-readers-list` and `delegates-list` are `[command, cursor,
namespace?]`. `public-policy` takes a repository key and returns `[visibility,
revision, lease_ms]`. `authorize-batch` takes a caller credential and key ID and
a delegate pair (all four empty means anonymous) followed by 1–125 repository
keys and returns one `[can_read, lease_ms]` row per key. All three require a
storage server's credential certificate and refuse keys outside that server's
namespace. The batch validates nonempty caller credentials even for public
repositories.

### Web automation credentials

The web account settings issue separate personal API tokens (`/settings/api`) and
MCP tokens (`/settings/mcp`). These are web-level bearer tokens for the web's
HTTP API only; no Scribe service accepts them.
Both are directly usable in `Authorization: Bearer …`; no access-token exchange
is required. `scribe_pat_<id>_<secret>` is accepted only on opted-in REST routes;
`scribe_mcp_<id>_<secret>` is accepted only on `/api/v1/mcp`. The ID is a random
128-bit lookup identifier, and the secret is a separate random 256-bit value.
The web database stores a SHA-256 digest of the entire public token, never its
recoverable secret. Each web token is bound to a separate Scribe automation
credential: a device key the web API generated, sealed in the web database,
and certified by auth. Neither is embedded in a URL, logged, or returned from
listing.

Creation requires a live browser password session. The API persists the new
key and operation ID before calling auth (`automation-create`, presented with
the browser session's own credential certificate), checks the operation outcome
before issuance, and only activates the web token after successful durable
issuance. Failed or uncertain issuance remains denied and queued for upstream
revocation. Tokens have named read scopes (`repositories:read`, `contents:read`,
`history:read`), explicitly selected write scopes such as `contents:write`, and
a fixed 7, 30, 90, or 365 day expiry. The upstream credential's label records
the token name and scopes (`web-api ci: flows:read, runners:write`) so
`scribe-auth credentials list` explains each ceiling. There are at most 100
active credentials per account, enforced transactionally per owner in
SurrealDB. Lists return the latest 200 records, with active records first.

Auth's `automation` (reader), `automation-write` (writer), `automation-maintain` (maintainer), and `automation-admin` (namespace-administration ceiling) credential kinds are bound to the issuing user and password
generation. Only a password session can create one, over its own mutual-TLS
connection, so nothing a storage server or gateway ever sees can mint one; the
audit row records the creating session. Each survives logout of the issuing
browser session, but user disable, password reset, expiry, or explicit
revocation invalidates it. It cannot create further automation credentials or
perform auth administration; it can only have auth certify a runner's key as
its derived child (below). Ordinary automation credentials remain capped to reader, writer, or maintainer without namespace-administrator
privileges. The separately selected `repositories:create` scope requests `namespace-admin`
delegation: namespace privileges apply only where the issuing user has a current Scribe
namespace-admin grant. Removing that grant removes provisioning access. This still
permits neither auth administration nor further delegation. Existing read-only credentials remain read-only; only new credentials
explicitly issued with the writer ceiling can write. A writer credential never
elevates a reader grant and cannot edit maintainer-only repository configuration. Storage continues checking current grants. The web middleware also
checks live identity on every automation request, including MCP discovery.

A runner (`runners:write`, reader ceiling) never receives the web token's
credential. At check-in it sends the public half of its own device key; the web
API derives a child credential for that key from the token's automation
credential (`automation-derive`: same kind, subject, ceiling and expiry,
revoked with its parent) and returns only its certificate, so a build machine
checks repositories out as the same read-only identity without any private key
leaving it. See [Flows](/docs/flows.md#registration-and-tokens).

Revocation denies new web requests as soon as the database update commits; the
background worker settles upstream revocation with its persisted operation ID.
Already-admitted operations may finish; a durable commit is never cancelled. Pending creations are left undisturbed for two
minutes before cleanup. Auth's normal bounded permission cache can briefly retain
an already-issued authorization; a token never extends that cache lifetime.
No privileged service credential or user password is stored for automation.

## Remote collaboration administration

[Workspace administration](/docs/workspace-administration.md) reuses repository roles:
`workspace list` shows the current principal's registrations, `--everyone`
requires Maintainer, and `workspace remove` permits an owner with read access or
a Maintainer for another owner. `--all-repos --everyone` is available to namespace
administrators across their accessible repositories, including through gateways.
Active workspace quotas are reclaimed by retirement; ownership claims and past
operation outcomes remain permanent. Forced unlock with `--repo` requires
Maintainer, even for the caller's own lost workspace, and works without a local
working copy. Anonymous public readers cannot list or retire registrations.

When auth is disabled, every connected client can list all registrations, retire
them, and force-unlock. A gateway preserves that policy; adding one does not add
authentication. See the [deployment permission table](/docs/workspace-administration.md#permissions-in-each-server-setup)
for direct and gateway setups with and without auth.

These are storage operations under existing grants. Auth-administrator credentials
still have no implicit storage access. Server-local doctor, backup/restore, and
repair remain protected by data-root access and the exclusive OS lock.
