Skip to content

Encryption at rest

FACE encrypts what it persists before it is written. It does this with one envelope scheme, from the shared security/secrets/envelope package, under one key-encryption key (KEK) that the platform supplies. FACE does not start without that key.

The envelope scheme

Every sealed value is an independent envelope:

  1. A fresh random 32-byte data-encryption key (DEK) is generated for each seal.
  2. The plaintext is encrypted with AES-256-GCM under that DEK.
  3. The DEK is wrapped with AES-GCM under a key derived from the KEK.
  4. The wrapped DEK, the nonces and the ciphertext are stored together, so one envelope is self-describing.

FACE writes the scheme’s version 2 format everywhere it seals. That covers the metadata store, the encrypted application root and the object store. Each of those writers opts in to version 2 in code, not through a setting.

Version 2 adds the following:

  • A per-envelope wrap key. The KEK never keys AES-GCM directly. Each envelope wraps its DEK under HKDF-SHA256(KEK, salt, "runink/envelope/v2/wrap") with a fresh 256-bit salt. No GCM key ever encrypts more than one message, so no per-key message budget needs tracking.
  • A key identifier. Each envelope carries an 8-byte key ID derived from the KEK by HMAC. Opening selects the right key directly, and a blob sealed under an unknown key says so. The ID reveals nothing about the key.
  • An authenticated header. The version, key ID, salt and nonces are all covered by the GCM tag, so they cannot be edited or spliced between envelopes.
  • Context binding (AAD). The caller’s additional authenticated data is bound in last. FACE uses it to tie each ciphertext to where it lives, so a sealed blob copied to another key, table or path fails to open. It is not merely hidden there.

Key rotation

The KEK is read from the secret CORE_ENVELOPE_KEK, a base64-encoded 32-byte value. To rotate it, publish the new key as CORE_ENVELOPE_KEK and move the old one into CORE_ENVELOPE_KEK_RETIRED. Existing envelopes still open because the key ID selects the retired key. Because only DEKs are wrapped by the KEK, rotation never requires re-encrypting bulk data.

A required KEK

  • Mounting the encrypted application root needs the KEK. Without it FACE stops, because that root holds the session store and a process without it could authenticate nobody.
  • Opening the metadata store without the KEK is also fatal. serve.go exits specifically on envelope.ErrKEKUnavailable.

No code path runs FACE with sealing switched off, and there is no setting that switches it off.

Where sealing applies

The encrypted application root

FACE’s sessions and application metadata live in its own appfs root, from the store/appfs package, mounted by grpc/internal/approot:

  • An app-specific key. The root’s key is HKDF-SHA256(KEK, info="runink/appfs/face"). A key or blob leaked from another Runink application opens nothing of FACE’s, even on a shared object store.
  • Path-bound blocks. The AAD of every block includes the application and the block’s full path, tenant segment included. A block moved to another path, tenant or application fails to open.
  • Two backends. With OBJECTSTORE_ENDPOINT set, the root is a scoped view of the object store. Without it, the root is appfs’s encrypted local backend, in FACE_APPFS_DIR. FACE has no in-memory mode.

The root holds sessions, user accounts, the compute-budget ledger and, under tenants/<FACE_TENANT>/, everything the customer configured or produced: connections and their credentials, application metadata, fetch snapshots, schedules, remediation overlays and routes, rule policies, and call transcripts.

The metadata store

FACE keeps a small SQLite key/value store (grpc/internal/storage). Every value in it is sealed before it is written, with no chosen subset. The AAD binds each value to its store, table, key and schema version. A row carries a flag saying whether it is sealed, so no stored value can be mistaken for ciphertext. At startup FACE re-seals any rows written by older builds, before the store serves a single read.

Values are sealed before SQLite turns them into pages. Every copy of the database file therefore carries those values as ciphertext, including any replica made by page-level replication.

The object store

The object-store client (store/objectstore) seals every object it writes with the same envelope scheme, bound to its bucket and key. FACE’s durable records go through it: session traces and feedback, observed lineage records, and (when an object store is configured) the tenant’s semantic-search corpus.

Credentials

Connector credentials get stricter handling than any other data FACE holds:

  • A connection record cannot hold a secret. The connection type in store/connections has no field a secret would fit in. When a connection arrives over the wire, internal/ai/connection_translate.go lifts every secret-named field (password, client secret, API key, token, service-account JSON) into a separate credential bundle. The secret-bearing fields in the wire schema are read on the way in and never written on the way out. A client that reads a connection back gets them empty.
  • Credentials are sealed rows, not files. Each credential bundle is a row in FACE’s appfs connections table, sealed under FACE’s derived key and bound to its path.
  • Credentials are not replicated through consensus. Only connection metadata goes through the raft log. Credential bundles do not.
  • Credentials never come from environment variables. Connector credentials come from the cockpit or a vault, never from the process environment. Google Maps and Routes clients take their key only from the credentials of their own connection.
  • Local files are owner-only. Where FACE writes local files (credential compatibility paths, and the knowledge corpus when no object store is configured), directories are created 0700 and files 0600. internal/ai/servicetoken_perms_test.go writes real corpus content through the real code path, then checks every file and directory mode.
  • The inference service token is never invented. INFERENCE_API_KEY is the only source of the token FACE presents to the inference plane. FACE never generates a fallback, and TestFaceNeverInventsAServiceToken guards that.

What these controls do not establish

  • Encryption at rest is not encryption in transit. Sealing happens before a write. Where a sealed payload crosses a network, it is ciphertext because it was sealed for storage, not because of any transport decision. Transport confidentiality comes from TLS (see Zero-trust architecture).
  • The metadata store is sealed value by value. Its values are sealed, but the database file itself is not encrypted as a whole.
  • Key custody belongs to the platform. Whoever holds CORE_ENVELOPE_KEK can open everything sealed under it. Generating, storing, distributing and rotating that key happens outside FACE.
  • Re-sealing does not reach old copies. Re-sealing at startup converts live rows. It does not rewrite copies that already exist in older backups or replica generations. Removing those is an operator step.
  • File modes are not encryption. 0600/0700 protects local files from other users on the host. It does not protect them from the host’s administrator or from someone who has the disk.