Skip to content
LogoLogo

Cold storage

General overview

The Cold Storage, or Shield, is where the cold share lives. It provides two main services:

  1. Cold share storage
  2. 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.1

Shield 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:

  1. The user introduces a password
  2. An encryption key is derived from this password
  3. 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:

  1. The passkey authenticator prompts the user to authenticate themselves
  2. If the authentication is successful, a 256-bit encryption key is derived
  3. This encryption key is used to both encrypt and decrypt their cold share
  4. 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:

  1. Prepare returns the new credential and changes nothing else. The old credential keeps working.
  2. 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 StatusError CodeMessage
429OTP_RATE_LIMITRate limit exceeded to generate OTP
422OTP_EXPIREDOTP is expired
400OTP_INVALIDATEDOTP invalidated after max failed attempts
400OTP_INVALIDReceived otp is invalid
400OTP_REQUESTED_BUT_NOT_SENTOTP was requested but not sent
428OTP_MISSINGOTP is required for this request
404OTP_RECORD_NOT_FOUNDOTP record not found for user
400OTP_USER_INFO_MISSINGMissing user information like email or phone number
400OTP_NOT_SUPPORTEDProject doesn't support OTP
409OTP_ALREADY_ENABLEDProject already has OTP enabled

Project errors

HTTP StatusError CodeMessage
404PJ_NOT_FOUNDProject not found
409EC_EXISTSEncryption part already exists
409EC_MISSINGThe requested share have project entropy and encryption part is required
400EC_INVALIDInvalid encryption part
400EC_INVALIDInvalid encryption session
409EC_ROTATEDEncryption key was rotated during the request, retry with the current encryption part
409EC_NO_PENDINGNo pending encryption key rotation; call prepare first
409EC_ROTATION_EXPIREDPending encryption key rotation expired; call prepare again
409EC_UNVERIFIABLEEncryption part cannot be verified: the project has no key check and no project share
409EC_NOT_MIGRATEDEncryption key is not migrated yet; read any share with the current part, then retry

Share errors

HTTP StatusError CodeMessage
404SH_NOT_FOUNDShare not found
409SH_EXISTSShare already exists
400SH_INVALIDProject-entropy shares cannot be imported

User errors

HTTP StatusError CodeMessage
404US_NOT_FOUNDUser not found
404US_EXT_NOT_FOUNDExternal user not found
409US_EXT_EXISTSExternal user already exists
400USER_CONTACTS_MISMATCHUser contact information mismatch
400EMAIL_INVALIDProvided Email is invalid
400PHONE_INVALIDProvided phone number is invalid

Provider errors

HTTP StatusError CodeMessage
400PV_UNKNOWNUnknown provider type
400PV_MISSINGMissing provider
404PV_NOT_FOUNDProvider not found
400PV_CFG_INVALIDInvalid provider config
400PV_CFG_INVALIDMissing key type
400PV_CFG_INVALIDInvalid PEM certificate
409PV_CFG_INVALIDJWK and PEM cannot be set at the same time
400PV_CFG_INVALIDJWK URL not allowed: use https on a public host, or ask the operator to allowlist the host in JWK_ALLOWED_HOSTS
409PV_EXISTSCustom authentication already registered for this project

Authentication errors

HTTP StatusError CodeMessage
401A_MISSINGMissing API key
401A_MISSINGMissing API secret
401A_MISSINGMissing token
401A_MISSINGMissing auth provider
401A_INVALIDInvalid API key or API secret
401A_INVALIDInvalid token
401A_INVALIDInvalid auth provider
409A_PENDING_NOT_PROVENCommit the API key (or secret) rotation with the pending key (or secret) from prepare
409A_ROTATION_EXPIREDPending API key rotation expired; call prepare again
409A_NO_PENDINGNo pending API key rotation for this key; call prepare again

General errors

HTTP StatusError CodeMessage
500INTERNALInternal error
500MISSING_NOTIFICATION_SERVMissing notification service
400BAD_REQUESTVarious messages