Documentation/Run your server
Authentication & access19 min read

Shared authentication and repository permissions

On this page

Scribe can use one scribe-auth service for several independent storage servers. Auth owns users, credentials, server identities, and grants. Storage servers own repositories and enforce permissions through AuthorizedRemote, a transport-neutral RemoteOps implementation. No repository contents or existence checks are sent to auth.

Authenticated storage connections use protocol 3 over TLS 1.3. Existing TCP2 operation encodings and all existing object/history formats are unchanged. An authenticated listener never negotiates plaintext or an older protocol. Auth itself uses the separate binary SAU1 protocol over TLS 1.3. There is no HTTP dependency, JWT signing, or custom cryptography. Server connections use Tokio and tokio-rustls; storage operations retain immediate redb durability.

Credentials and roles

Every user has an immutable random 128-bit ID, an immutable username, enabled status, and a separate auth-administrator flag. Regular users log in with a username and password to obtain an opaque, 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.

Tokens contain 256 bits of OS randomness; only their full BLAKE3 digests are stored at auth. Tokens are individually revocable and expire. Disabling a user disables all their tokens. The initial admin identity is reserved and cannot be disabled; its bootstrap token can be revoked after another administrator/token 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. This accepts a UPN as a Scribe identifier; it does not connect to AD or verify a mailbox. No storage or protocol migration is needed.

With the management connection options configured as below:

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

Each registered storage-server identity belongs to exactly one namespace. Give each server its own revocable service token, even when several servers share a namespace. Service tokens can validate users and read decisions only for that namespace. They cannot list users, issue tokens, or edit grants.

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.

RoleAllowed
ReaderRepository discovery and metadata, history, object presence/download, workspace registration, branch and merge reads
WriterReader plus object uploads, submits, branch create/rename, own workspace locks
MaintainerWriter plus generation-checked forced unlock
Namespace administratorAll repository roles in that namespace, plus repository creation
Auth administratorCentral user/token/server/grant administration; no implicit storage access

Granting namespace administration is an explicit, separately audited command. Repository maintainers do not gain permission to manage central users/grants. Access without a matching grant is denied. Repository discovery filters out inaccessible repositories. Operation outcomes are visible only to their recorded user, who must still have repository read access.

Setup

Build all three binaries with cargo build --release --locked.

Provision PEM certificates with scribe-auth certs or scribe-server certs; both use the same offline helper. An existing certificate authority can also provide them. Certificates must have SANs matching the hostname or IP clients use. Do not use the checked-in test certificates. Restrict credential/key directories to the service account; new credential files are mode 0600 on Unix and inherit the directory ACL on Windows. Secrets are read from files, not command-line arguments, and are not stored in .scribe/ workspaces or logged.

Generate a private CA and server certificates

Install OpenSSL 3 or newer on the provisioning machine. Scribe invokes it directly without a shell; it is not needed to run the services. Use certs --openssl /absolute/path/to/openssl ... to select a trusted executable (on macOS the system openssl may be LibreSSL; use OpenSSL 3 instead).

Create the CA once, then issue separate keys/certificates for auth and storage:

sh
scribe-auth certs init-ca --out ./studio-ca
scribe-auth certs issue --ca ./studio-ca --out ./auth-tls \
  --dns localhost --ip 127.0.0.1
scribe-server certs issue --ca ./studio-ca --out ./storage-tls \
  --dns localhost --ip 127.0.0.1

Replace localhost with 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. Each issue directory contains server.pem, server-key.pem, and a copy of ca.pem. Keys use ECDSA P-256; the CA can sign leaf certificates only, and issued certificates permit server authentication only. The helper verifies the certificate chain and key pair before reporting success. It does not create users or tokens.

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, first restrict the parent directory's ACL to the operator/service account; generated files inherit it. Private keys are unencrypted so services can start unattended. Keep studio-ca/ca-key.pem offline and backed up; give each server only its own leaf key/certificate and the public CA certificate. Normal scribe clients authenticate with their username and password and save their own session. 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. For renewal, rerun issue with the same CA and a new output directory, then configure the service to use the new files and restart it. Existing clients keep trusting the same CA. Replacing the CA itself requires explicit approval of the new trust on each client. 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.

In the setup below, use auth-tls/server.pem / auth-tls/server-key.pem in place of auth.pem / auth-key.pem, and the corresponding storage-tls files for storage. Use studio-ca/ca.pem wherever ca.pem is shown.

Start services and provision users

Initialize auth once. The administrator token output belongs outside the new empty auth data directory:

sh
scribe-auth init --data-dir ./auth-data --admin-token-out ./admin.token \
  --pepper-out ./auth.pepper
scribe-auth serve --data-dir ./auth-data --pepper-file ./auth.pepper --listen 127.0.0.1:7443 \
  --tls-cert ./auth.pem --tls-key ./auth-key.pem

Auth uses async connection tasks and a bounded blocking storage pool. serve accepts --max-connections (default 128) and --storage-workers (default 16, range 1–1024). TLS handshakes and incomplete frames do not occupy storage workers. I/O and worker admission have a 10-second timeout. Shutdown closes idle connections; after 30 seconds it interrupts remaining network I/O and waits for any started storage jobs to finish before releasing the root lock.

Auth uses the same scribe-log backend as the storage server. It logs to stderr by default; for a long-running service, enable daily files:

sh
scribe-auth serve --data-dir ./auth-data --pepper-file ./auth.pepper --listen 127.0.0.1:7443 \
  --tls-cert ./auth.pem --tls-key ./auth-key.pem \
  --log-dir ./auth-logs --log-format json --log-keep-files 14

Files are named scribe-auth.YYYY-MM-DD.log. Rotation happens on the first write after midnight UTC without a restart. Retention defaults to 14 files; --log-keep-files must be at least 1 and affects only the auth log prefix. File logs have no terminal colors. Without --log-dir, text stderr logs automatically use terminal colors and respect NO_COLOR and TERM=dumb.

All auth commands accept --log-level, --log-filter, --log-format text|json, -v (debug), -vv (trace), and -q (errors only). Explicit logging flags take precedence over SCRIBE_LOG; the default level is info. Conflicting options and invalid filters are rejected before a command mutates auth state. Command results remain on stdout; --log-format json changes diagnostics only.

Lifecycle events include auth.listening (with the bound listen address), auth.stopped, and file-logged startup/runtime failures as auth.failed. Existing auth library diagnostics also reach this subscriber. Logs do not include tokens or raw request contents. The bounded background writer drains on graceful shutdown; a stalled sink can drop diagnostics, counted by the shared logging backend, and forced termination can lose buffered events. These operational logs are separate from durable auth state and auditing.

The management commands below use a shell helper to keep the examples short. Run them in another terminal while auth is running:

sh
auth() {
  scribe-auth --auth 127.0.0.1:7443 --auth-ca ./ca.pem \
    --admin-token-file ./admin.token "$@"
}
auth users add alice
auth users add bob
auth users list
auth users password alice --password-file ./alice.password
auth users password bob --password-file ./bob.password
auth servers add storage-a --namespace studio
auth tokens create storage-a --server --name daemon --out ./storage-a.token
auth namespace-admins set alice studio

Administrator/service tokens default to 90 days; --days accepts 1–3650. tokens create accepts only auth administrators or registered storage servers. Regular users must log in with their password. Only init issues the nonexpiring bootstrap token. Token creation writes the secret file durably before transmitting its digest; it never prints the secret. Choose a fresh output path for a new token. A retry can reuse its existing token file.

Initialize storage and its separate security state explicitly:

sh
scribe-server init --data-dir ./storage-data --config-out ./storage.toml
scribe-server auth-init --data-dir ./storage-data --namespace studio
scribe-server serve --config ./storage.toml --auth=127.0.0.1:7443 \
  --auth-namespace studio --auth-ca ./ca.pem \
  --auth-token-file ./storage-a.token \
  --tls-cert ./storage.pem --tls-key ./storage-key.pem

--listen still uses the existing bind-address syntax, such as tcp://0.0.0.0:7447; with auth enabled that socket serves TLS only. The runtime listen file reports tls://host:port.

Alternatively add this section to storage.toml. File paths are relative to the configuration file; CLI paths are relative to the working directory.

toml
[auth]
endpoint = "127.0.0.1:7443"
namespace = "studio"
ca = "ca.pem"
token_file = "storage-a.token"
tls_cert = "storage.pem"
tls_key = "storage-key.pem"

All six settings are required together. Missing or partial configuration fails startup. auth-init durably marks the root with the required feature auth-ownership-v1 before creating the ownership database, so older binaries refuse to open it. A marked root or one containing auth-state.redb refuses unauthenticated startup. Auth-state namespace changes require an explicit migration; changing a config string cannot silently remap grants.

Create a repository and grant access to its ID:

sh
scribe --server tls://localhost:7447 login alice \
  --auth tls://localhost:7443 --auth-ca ./ca.pem
scribe --server tls://localhost:7447 repo create game
scribe --server tls://localhost:7447 repo list

auth grants set bob studio/REPOSITORY_ID --role reader
auth grants list --repo studio/REPOSITORY_ID

Use the actual full ID printed by repo list. The first connection prompts for trust as described below. Subsequent client commands reuse the saved session automatically. --token-file / SCRIBE_TOKEN_FILE remain explicit overrides for administrator credentials or separately managed session files. The workspace stores its tls:// server URL but no credential. Supplying credentials with a tcp:// URL is an error; there is no downgrade fallback.

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, 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; Windows inherits the parent ACL.

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. Earlier unreleased auth roots are rejected by the new format marker; create a fresh test auth root explicitly. Never delete an existing root to fix startup.

User login and session lifetime

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 by changing the user's password 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 client journal stores a BLAKE3 password commitment keyed by the administrator token; 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 tokens remain independent of user sessions. Even an auth administrator's password-issued session cannot invoke auth administration; use the separate administrator token for that purpose.

Configure the lifetime of new sessions on the auth service:

sh
scribe-auth serve --data-dir ./auth-data --pepper-file ./auth.pepper --listen 127.0.0.1:7443 \
  --tls-cert ./auth.pem --tls-key ./auth-key.pem --session-ttl-days 180

--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. Token revocation, a disabled user, or a password reset also prevents access (subject to the existing authorization-cache bound of 30 seconds).

scribe login USER --auth tls://HOST:PORT prompts for Password: with input hidden, then saves a session for the selected --server (or workspace server), under the per-user config root's sessions/ directory. Use --password-file FILE for automation or redirected input; without that flag, 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 our assertion; panic unwinding also restores it. Other applications' Secure Input assertions are preserved. Windows/Linux retain their native echo-disabled input; there is no portable terminal API providing equivalent keyboard interception protection. For SSH, keyboard protection belongs on the local computer.

The password file is not copied into session state. New directories/files use 0700/0600 on Unix; Windows inherits the parent ACL. No credential is saved under .scribe/. 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.

Log out with:

sh
scribe --server tls://localhost:7447 logout --auth-ca ./ca.pem

Logout revokes the current session at auth before removing the saved credential. A connection failure keeps the pending operation; rerun the same command. Commands refuse to use a session with a pending logout. Logging in again creates a new session; sessions on other machines remain valid until revoked, reset, disabled, or expired.

For durable retries without storing recoverable bearer tokens at auth, login creates a random token candidate and operation ID locally, flushes both, and sends the candidate's digest with the username and password. Successful password verification accepts that digest with a server-selected expiry. The token is usable only after this durable acceptance. A retry first requests login-outcome; it returns the original expiry and does not mint another token. The password is not part of persisted intent/outcome data. Saved sessions are scoped to the normalized storage hostname and port, and retain the auth endpoint needed for logout. Explicit --token-file / SCRIBE_TOKEN_FILE takes precedence.

Login hashing runs on an admitted blocking worker, outside the publication gate and database write transaction. Only one hash verification runs at a time; concurrent logins receive a retryable busy error instead of occupying every worker. At most ten attempts per exact username are admitted per 60 seconds; the bounded, 4096-entry attempt table is in memory and resets on service restart. 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.

Auth protocol and storage additions (unreleased schema 1 / SAU1)

The SAU1 request layout remains a str_u16 credential followed by a 16-byte operation ID and bounded string arguments, all lengths little-endian. The credential bound is now 1024 UTF-8 bytes. login / login-outcome use the credential field for the password; every other command requires a 64-character lowercase hexadecimal bearer token. Login arguments are [command, username, token_digest, label], with a nonzero persisted operation ID. A successful login or known outcome returns [["ok", expiry_unix_ms]]; an unknown outcome returns no rows. logout / session-outcome take only the command argument and return [["ok"]] or no rows for an unknown outcome. users-password arguments are [command, username, password_commitment, random_generation_id], followed by one transient str_u16 new password (12–1024 UTF-8 bytes). That extra field is exclusive to users-password and is excluded from persisted argument encoding. The commitment is keyed BLAKE3 over the password with a key derived from the administrator token using context Scribe auth admin password commitment v1. The durable intent uses canonical encode_intent with a zero operation ID and all four arguments, followed by password bytes, keyed using the pepper and context Scribe auth password operation intent v1. Both keys use BLAKE3's derive_key. Password changes use the existing durable administrative-outcome path; replay cannot replace the original password.

The SCRIBE_AUTH marker is scribe-auth\nschema=1\nprotocol=1\npassword=argon2id-pepper-v1\n.

Additional auth_v1 keys use the existing single-row SAU1 response encoding:

KeyRow
meta/pepper-idfull BLAKE3 fingerprint of the raw 32-byte pepper
passwords/USERNAMEpeppered Argon2id PHC string, random 128-bit generation as lowercase hex
tokens/DIGEST for sessionssession, username, expiry Unix ms, enabled, label, password generation
sessions/OPERATION_IDimmutable user ID, BLAKE3 intent digest, ok, original expiry Unix ms
session-logout/OPERATION_IDtoken digest

The login intent hashes canonical encode_intent bytes with a zero operation ID and [username, token_digest, label]; no password bytes are hashed into it. Administrator/service token rows retain five fields. Session rows carry six. Each session mutation publishes its token row and outcome in the same immediate transaction before acknowledging. Repository/object formats and storage-protocol frames are unchanged.

Remembered client trust

On first connection, scribe completes a TLS-only discovery handshake and shows the server CA fingerprint. Type yes to approve and save it. There is no CA download/import step. Discovery sends no Scribe frames or tokens; after approval is durably saved, the client reconnects and verifies TLS using that saved CA before sending credentials. Hostname, certificate validity, chain, and handshake signatures are checked, including during discovery.

This is trust on first use: an attacker controlling the first connection can present their own CA. Compare the displayed fingerprint with the value your administrator provides through a trusted channel to authenticate that first connection. Fingerprints are full BLAKE3 digests of the CA's DER certificate bytes, prefixed with BLAKE3:. Approval is scoped to the normalized hostname and port; another server using the same CA does not inherit that approval.

Trust is shared across the user's workspaces and stored outside .scribe/:

  • macOS: ~/Library/Application Support/Scribe/trust
  • Windows: %LOCALAPPDATA%\Scribe\trust
  • Linux: $XDG_CONFIG_HOME/scribe/trust, or ~/.config/scribe/trust

--config-dir or SCRIBE_CONFIG_DIR overrides the shared Scribe user configuration root; trust lives in its trust/ subdirectory, leaving the same root available for future user settings. New directories/files are private on Unix; Windows uses the parent directory's ACL. Records contain the endpoint and public CA certificate, never a token. Corrupt records fail closed and are never silently replaced.

Trust can also be managed without a token or application connection:

sh
scribe --server tls://localhost:7447 trust add
scribe --server tls://localhost:7447 trust show
scribe --server tls://localhost:7447 --trust-fingerprint BLAKE3:OLD_DIGEST trust forget

trust show is offline. trust forget requires the exact saved fingerprint to prevent accidental removal. If the CA changes, connections fail without sending a token or modifying trust. Verify the change with the administrator, forget the old trust explicitly, then approve the new CA. Routine leaf certificate renewal under the saved CA needs no new approval.

Noninteractive commands and --json never prompt or automatically accept. Preapprove using trust add --trust-fingerprint BLAKE3:EXPECTED_DIGEST, or supply that flag on the first normal command. A mismatching fingerprint fails and cannot replace an existing trust record. The fingerprint is public and suitable for a CI configuration variable.

Explicit --tls-ca / SCRIBE_TLS_CA remains available for managed deployments; it uses the supplied CA instead of remembered trust. Storage-to-auth and auth-administration connections still use their operator-configured --auth-ca; users of the normal scribe client do not need that file.

The storage executable also offers these management conveniences using operator credentials, independently of the daemon's service credential:

sh
scribe-server users --auth 127.0.0.1:7443 --auth-ca ./ca.pem \
  --admin-token-file ./admin.token list
scribe-server permissions --auth 127.0.0.1:7443 --auth-ca ./ca.pem \
  --admin-token-file ./admin.token set bob studio/REPOSITORY_ID --role writer

Add another registered server, service token, and initialized storage root pointing to the same auth endpoint to share users across instances.

Administration and retries

users enable/disable, servers enable/disable, tokens revoke DIGEST, grants revoke USER REPOSITORY, and namespace-admins set/revoke provide the corresponding lifecycle operations. users add --administrator creates another central administrator. tokens list shows digests, not tokens.

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 administrative results with timestamp, immutable actor ID, command, operation ID, and status. Exact request fingerprints and outcomes are kept in the operation table; audit rows contain no credentials.

Administrative mutations persist their operation ID and exact request under $HOME/.scribe/auth-admin (or --state-dir) before transmission. Unknown outcomes retain that pending file. 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. Confirmed outcomes remove the local pending request. Local administrative invocations use an OS lock to avoid racing token files or pending requests.

Sessions, revocation, and failures

Storage caches each positive or negative authorization decision per credential/session and repository for at most 30 seconds, capped by both user-token and service-token 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, and the existing storage connection cap still applies.

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 token, or changing a grant affects new operation admissions within 30 seconds. 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, retaining the protected credentials separately.

Each secure control session returns a random 256-bit binding secret. Up to four additional data connections must authenticate with the same credential, prove the binding secret, and reference a live control session on that server. Closing the control connection stops further requests on its data connections.

Protocol v3 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 authenticated user supplies commit 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.

For TOML-based local cluster startup, pepper provisioning, and idempotent user bootstrap, see the cluster guide.

Source docs/auth.mdSnapshot 93d02b17