Cold storage
General overview
The Cold Storage, or Shield, is where the cold share lives. It provides two main services:
- Cold share storage
- Cold share recovery
Shield relies on the same authentication system as the Hot Storage. That is, an OIDC-compatible authentication system needs to be set up and specified for Shield to be able to recognize and validate users.
Architecture
Shield is an HTTP server exposing an API. This API is mostly used by iFrame, and no hand-crafted requests should be needed or done by any other HTTP client than that of iFrame's. There are a couple of exceptions for this: handling project and authentication providers. These two exceptions are explained in more detail in further sections.
In any case, the API specifics can be found in Shield's official repository README.md.
Shield is fully written in Go and it relies on a PostgreSQL database for its persistence layer.
All database interactions are done using Go's ORM, gorm. Database migrations are done via goose.
The app's entrypoint is CLI-based. Its two main command branches are db and server.
2025/08/04 09:07:16 INFO Starting OpenFort Shield
Root command
Usage:
shield [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
db Database operations
help Help about any command
server Run the server
Flags:
-h, --help help for shield
Use "shield [command] --help" for more information about a command.
The db command offers two sub-commands: create-migration and migrate. create-migration creates migrations reflecting the difference between what's modeled in Shield and what's available in the DB schema. This command doesn't take indexing into account so manually review all generated migrations. migrate applies all the migrations that are not present in the current target DB.
The server command starts Shield. server also detects pending DB migrations and applies them.
Shield's codebase is structured following a hexagonal architecture approach. That is, each layer is self-contained, and different layers communicate only through agreed-upon ports. When exploring the codebase, start in server.go and then go all the way down through whatever handler, service, and repository you need to look for.
Shield also features mock entity repositories. This allows anyone to run shield's tests without having to have an actual database up and running. Tests can be run via go test or gotestsum as it's usual in Go projects.
Prerequisites
Shield requires a PostgreSQL database. Configure the following environment variables
# DB related fields, those do NOT have a default value
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
# TLS mode for the database connection. Note the underscore: Shield reads
# DB_SSL_MODE and defaults it to `disable`, so a misspelled name yields an
# unencrypted connection rather than an error. Set `require` in production.
DB_SSL_MODE=
# URL to the hot storage, used as base URL for API
OPENFORT_BASE_URL=
# Shield's port, default is 8080
PORT=
# Requests per second, default is 100
RPS=
# Read timeout, default is 5s
READ_TIMEOUT=
# Write timeout, default is 10s
WRITE_TIMEOUT=
# Idle timeout, default is 15s
IDLE_TIMEOUT=
# CORS Max Age, default is 86400
CORS_MAX_AGE=
# CORS extra allowed headers, empty by default
CORS_EXTRA_ALLOWED_HEADERS=
# Hosts (host or host:port, comma separated) that custom-provider JWK URLs may
# use even though they are internal or plain HTTP. Empty by default.
JWK_ALLOWED_HOSTS=
Custom-provider JWK URLs must be https on a public host. Shield resolves the
host itself, refuses private, loopback, link-local (including cloud metadata)
and other non-public addresses, and re-checks every redirect, so a tenant
cannot point it at internal services. The bundled auth service is reached over
plain HTTP on the internal network, so docker-compose.yml sets
JWK_ALLOWED_HOSTS=authservice:3000 (override with
COLD_STORAGE_JWK_ALLOWED_HOSTS).
OPENFORT_BASE_URL refers to the Hot Storage. Shield learns about the authentication service once an Authentication Provider is configured for a certain project.
Versioning
The provided docker-compose.yml pins Shield to a specific release image. The current version is v0.4.1:
image: ghcr.io/openfort-xyz/shield:v0.4.1Shield dropped MySQL support in v0.3.0 and speaks only the PostgreSQL wire
protocol from that release on. Earlier tags such as v0.2.40 expect MySQL and
will not start against the database this stack provides.
Shield images are published to the GitHub Container Registry under ghcr.io/openfort-xyz/shield. To check for newer versions, visit the Shield repository releases or list available tags in the registry.
When upgrading, review the release notes for breaking changes before updating the tag in your docker-compose.yml. After updating, restart the container — Shield automatically applies any pending database migrations on startup.
Deployment
Shield is ready to be Dockerized. No environment variables or build args are needed for this step. A regular docker build . -t xyz ... command works.
A Shield container can be then started with a regular docker run command. Environment variables can be either specified in the run command or by mounting a .env file.
Shield starts its HTTP server on port 8080, so whatever port mapping intending to make a Shield container reachable from the outside should use 8080 as the container port.
Shield doesn't feature any kind of HTTPS support by itself. Secure communications must be enforced via load balancers and/or other front mechanisms.
Shield can also run locally (for example, go cmd/main.go server), but this is not recommended for production.
The provided Docker image expects the user providing the proper CLI commands. That is, the image can be used for either DB migrations or running the actual server.
Security
Shield doesn't implement any kind of HTTPS handling by itself: it needs to be done somewhere else and proper routing needs to be implemented in the corresponding load balancers. Same goes for cert validation, IP whitelisting, and more. Shield only cares about user and project authentication and it does so by delegating it to the external auth server.
Secure communications are essential here: Shield communicates unencrypted shares to the iFrame. Any compromise in either endpoint or in the communication channel exposes the share to unauthorized parties.
Core concepts
Projects
A project is a group of users. Each project also features an encryption key needed for automatic share recovery in case it's needed.
Users
As mentioned in other sections, the user is the core concept of Keys. Users are who store shares and might need them to recover their keys afterwards.
A user belongs to exactly one project.
Recovery methods
Password recovery
Shares can be encrypted/decrypted in iFrame based on user-originated entropy.
User-based encryption DOESN'T happen in Shield, it's fully client-side. Shield only stores the encrypted share and blindly sends it back to the user along with its encryption parameters.
Shield doesn't know, and can't know, if the share encryption was performed as stated in the encryption parameter set.
Shield also doesn't retrieve externally stored shares. A share can be stored somewhere else (for now, Google Drive and iCloud). Shield only stores the reference to where the share is stored, but it's up to the client (that is, the iFrame) to recover the actual share.
When this method is chosen the following happens:
- The user introduces a password
- An encryption key is derived from this password
- This password can then be used to encrypt/decrypt their shares
Both encryption and decryption happen in iFrame. Shield stores whatever the user sends to it along with the encryption parameters that have been used to perform such encryption.
The encryption parameters are salt, iterations, length and digest. digest refers to the hash algorithm used by PBKDF2, not the digested secret itself or anything or the sort.
Following the most common recommendations, salt should be at least 128 bits long and at least 600_000 iterations should be used along with PBKDF2.
length references the number of bytes of the resulting derived key, not bits.
Automatic recovery
Cold share recovery can be also done via project-wide entropy. In this case, both encryption and decryption happen server-side (understanding server as shield).
When a project is created, an encryption key for this particular project is created and split between shield and the project. This key is reconstructed and used to encrypt/decrypt shares whenever the entropy source is the project.
This type of share recovery can be enhanced with OTP (One-Time Password) verification. See OTP for Automatic Recovery for detailed documentation.
Passkey recovery
Cold shares can be encrypted using passkeys.
Whenever a user wants to encrypt/decrypt their share the following happens:
- The passkey authenticator prompts the user to authenticate themselves
- If the authentication is successful, a 256-bit encryption key is derived
- This encryption key is used to both encrypt and decrypt their cold share
- Shield then remembers the internal passkey ID and certain environment details for that share
Authentication process
OpenSigner does not rely on webauthn authentication ceremonies. In other words, OpenSigner does not support issuing server-side challenges and validating them. As discussed in previous chapters, user authentication is done and managed by the external auth provider, and Shield uses it as its only source of truth regarding authentication. Shield still requires proper user authentication when storing and retrieving shares encrypted using passkeys.
From here on, authentication refers to the process of the user successfully authenticating within the passkey's ecosystem (by introducing a PIN, biometrics, and so on). This authentication, if successful, makes the authenticator return the following:
- A signed challenge (which we don't use)
- A derived 256-bit encryption key
Encryption key
Modern passkeys support the Pseudo Random Function (PRF) extension. This extension allows the user to produce pseudo-random noise in a deterministic way using their private key (which never leaves the passkey's authenticator) and some seed. OpenSigner relies on this extension to produce a 256-bit encryption key for symmetric encryption of the cold share.
Note that to use this PRF output, both the private key and the seed are needed.
The seed can remain public and having access to the private key requires the user to be authenticated within the passkey's authenticator. OpenSigner uses the user's external ID as the fixed seed.
Using PRF makes server-side challenges unnecessary: an attacker cannot benefit from an old signature since there's no verifier to do replication attacks against, and PRF outputs require being properly authenticated within the passkey's ecosystem.
Encrypting and decrypting
The derived 256-bit key is then used to symmetrically encrypt and decrypt the cold share.
OpenSigner uses AES-CBC in this case. AES-CBC is sufficient since shares are of fixed size, rendering potential oracle padding attacks useless.
Both the encryption and decryption happen client-side: Shield only sees and interacts with the encrypted contents of the share.
Non-authenticated encryption is acceptable here: decrypting garbage causes further checks (such as private key to address derivation) to fail.
What Shield stores
Shield stores the encrypted contents of the cold share along with the internal passkey ID.
By internal passkey ID we mean the identifier the passkey authenticator gave to that particular passkey.
OpenSigner also stores the environment in which the passkey was created. This information is mainly extracted from the User-Agent token and is meant for both hinting and tracking purposes.
Rotating project credentials
A project's encryption key, API key and API secret can each be replaced without downtime. Each rotation takes two calls:
- Prepare returns the new credential and changes nothing else. The old credential keeps working.
- Commit proves the new credential was received and makes it current. The old credential stops working.
The prepare response is the only copy of the new credential, which is why
nothing is replaced until the commit sends it back. Both calls are safe to
retry: preparing again replaces the pending credential, and repeating a
successful commit returns already_committed (encryption key) or current
(API key and API secret). A pending credential expires after 15 minutes.
Encryption key
# 1. Prepare: send the current part, receive the new one
curl -X POST "$SHIELD/project/rotate-encryption-key/prepare" \
-H "X-API-Key: $API_KEY" -H "X-API-Secret: $API_SECRET" \
-d '{"encryption_part": "'"$CURRENT_PART"'"}'
# -> {"encryption_part": "<new part>"}
# 2. Commit: send both parts
curl -X POST "$SHIELD/project/rotate-encryption-key/commit" \
-H "X-API-Key: $API_KEY" -H "X-API-Secret: $API_SECRET" \
-d '{"encryption_part": "'"$CURRENT_PART"'", "new_encryption_part": "'"$NEW_PART"'"}'
# -> {"status": "committed"}Commit re-encrypts every share that uses automatic recovery (project entropy) in one database transaction. Password and passkey shares are encrypted client-side and are not touched. After the commit:
- The old part, and any encryption session registered with it, is rejected
with
400 EC_INVALID. Update every place that stores the part, such as your backend's configuration, before or right after committing. - A share write that started before the commit and finishes after it is
rejected with
409 EC_ROTATED. Retry it with the new part.
Shield stores a check value of each project key and rejects a part that does
not reconstruct it, so a stale part can never encrypt a share under a key
nobody holds. Keys created before this check existed get one on their first
prepare, which verifies the part by decrypting the project's shares; a project
with such a key and no project share yet cannot be rotated
(409 EC_UNVERIFIABLE) until it has one.
Rotate only once every Shield replica runs a release with rotation: an older replica writes shares without the rotation guard.
Commit runs in a single transaction. For projects with many shares, make sure
WRITE_TIMEOUT covers it; if a commit's response is lost, repeating the same
commit completes it.
API key
# 1. Prepare with the current key
curl -X POST "$SHIELD/project/rotate-api-key/prepare" \
-H "X-API-Key: $API_KEY" -H "X-API-Secret: $API_SECRET"
# -> {"api_key": "<pending key>"}
# 2. Commit with the PENDING key
curl -X POST "$SHIELD/project/rotate-api-key/commit" \
-H "X-API-Key: $PENDING_KEY" -H "X-API-Secret: $API_SECRET"
# -> {"api_key": "<pending key>", "status": "committed"}The API key is also configured in the iframe (shieldAPIKey), so redeploy it
with the new key when you commit. Project endpoints reject the old key
immediately. Share endpoints cache API-key lookups for 60 seconds per Shield
instance, so with several replicas the old key can keep working there for up
to a minute.
API secret
# 1. Prepare with the current secret
curl -X POST "$SHIELD/project/rotate-api-secret/prepare" \
-H "X-API-Key: $API_KEY" -H "X-API-Secret: $API_SECRET"
# -> {"api_secret": "<pending secret>"}
# 2. Commit with the PENDING secret
curl -X POST "$SHIELD/project/rotate-api-secret/commit" \
-H "X-API-Key: $API_KEY" -H "X-API-Secret: $PENDING_SECRET"
# -> {"status": "committed"}Update the secret wherever your backend calls Shield with it (for example
SHIELD_API_SECRET) when you commit. The old secret is rejected immediately
on every endpoint and every replica. The pending secret authenticates only
the commit. An API key and an API secret rotation can be in flight at once;
commit the secret with whichever key is current.
API error codes
Shield returns an HTTP status, an error code, and a descriptive message on failure:
{
"message": "Error description",
"code": "ERROR_CODE"
}OTP errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 429 | OTP_RATE_LIMIT | Rate limit exceeded to generate OTP |
| 422 | OTP_EXPIRED | OTP is expired |
| 400 | OTP_INVALIDATED | OTP invalidated after max failed attempts |
| 400 | OTP_INVALID | Received otp is invalid |
| 400 | OTP_REQUESTED_BUT_NOT_SENT | OTP was requested but not sent |
| 428 | OTP_MISSING | OTP is required for this request |
| 404 | OTP_RECORD_NOT_FOUND | OTP record not found for user |
| 400 | OTP_USER_INFO_MISSING | Missing user information like email or phone number |
| 400 | OTP_NOT_SUPPORTED | Project doesn't support OTP |
| 409 | OTP_ALREADY_ENABLED | Project already has OTP enabled |
Project errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 404 | PJ_NOT_FOUND | Project not found |
| 409 | EC_EXISTS | Encryption part already exists |
| 409 | EC_MISSING | The requested share have project entropy and encryption part is required |
| 400 | EC_INVALID | Invalid encryption part |
| 400 | EC_INVALID | Invalid encryption session |
| 409 | EC_ROTATED | Encryption key was rotated during the request, retry with the current encryption part |
| 409 | EC_NO_PENDING | No pending encryption key rotation; call prepare first |
| 409 | EC_ROTATION_EXPIRED | Pending encryption key rotation expired; call prepare again |
| 409 | EC_UNVERIFIABLE | Encryption part cannot be verified: the project has no key check and no project share |
| 409 | EC_NOT_MIGRATED | Encryption key is not migrated yet; read any share with the current part, then retry |
Share errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 404 | SH_NOT_FOUND | Share not found |
| 409 | SH_EXISTS | Share already exists |
| 400 | SH_INVALID | Project-entropy shares cannot be imported |
User errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 404 | US_NOT_FOUND | User not found |
| 404 | US_EXT_NOT_FOUND | External user not found |
| 409 | US_EXT_EXISTS | External user already exists |
| 400 | USER_CONTACTS_MISMATCH | User contact information mismatch |
| 400 | EMAIL_INVALID | Provided Email is invalid |
| 400 | PHONE_INVALID | Provided phone number is invalid |
Provider errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 400 | PV_UNKNOWN | Unknown provider type |
| 400 | PV_MISSING | Missing provider |
| 404 | PV_NOT_FOUND | Provider not found |
| 400 | PV_CFG_INVALID | Invalid provider config |
| 400 | PV_CFG_INVALID | Missing key type |
| 400 | PV_CFG_INVALID | Invalid PEM certificate |
| 409 | PV_CFG_INVALID | JWK and PEM cannot be set at the same time |
| 400 | PV_CFG_INVALID | JWK URL not allowed: use https on a public host, or ask the operator to allowlist the host in JWK_ALLOWED_HOSTS |
| 409 | PV_EXISTS | Custom authentication already registered for this project |
Authentication errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 401 | A_MISSING | Missing API key |
| 401 | A_MISSING | Missing API secret |
| 401 | A_MISSING | Missing token |
| 401 | A_MISSING | Missing auth provider |
| 401 | A_INVALID | Invalid API key or API secret |
| 401 | A_INVALID | Invalid token |
| 401 | A_INVALID | Invalid auth provider |
| 409 | A_PENDING_NOT_PROVEN | Commit the API key (or secret) rotation with the pending key (or secret) from prepare |
| 409 | A_ROTATION_EXPIRED | Pending API key rotation expired; call prepare again |
| 409 | A_NO_PENDING | No pending API key rotation for this key; call prepare again |
General errors
| HTTP Status | Error Code | Message |
|---|---|---|
| 500 | INTERNAL | Internal error |
| 500 | MISSING_NOTIFICATION_SERV | Missing notification service |
| 400 | BAD_REQUEST | Various messages |
Related
- See how users and projects authenticate against Shield.
- Automatic recovery can require OTP; compare options in Recovery methods.