Skip to content

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 golang and it relies on a MySQL-compatible database for its persistence layer.

All database interactions are done using golang'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 golang projects.

Prerequisites

Shield relies on a MySQL-compatible database for it to run. Shield has been developed using MariaDB. This along with other specifics entails taking care of certain environment configurations

# DB related fields, those do NOT have a default value
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
# URL to the Hot Storage, will be 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=

OPENFORT_BASE_URL refers to the Hot Storage. Shield learns about the authentication service once an Authentication Provider is configured for a certain project.

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

The 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 will store 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.

Errors

Shield API returns various error codes depending on the type of failure encountered. Each error response includes an HTTP status code, an error code, and a descriptive message.

Error Response Format

{
  "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

Share Errors

HTTP StatusError CodeMessage
404SH_NOT_FOUNDShare not found
409SH_EXISTSShare already exists

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

General Errors

HTTP StatusError CodeMessage
500INTERNALInternal error
500MISSING_NOTIFICATION_SERVMissing notification service
400BAD_REQUESTVarious messages
Presented By
Openfort Logo