# OpenSigner | Non-Custodial Wallet Key Management > Open-source and non-custodial and self-hostable private key management. ## Deployment Scenarios Due to the modular nature of the system, users can run or implement their own components. Hybrid scenarios are possible where Openfort hosts some components while others are self-hosted. ### Scenario Overview This section evaluates the following scenarios, where the components are hosted by a third party such as Openfort (**TP**) or self-hosted (**SH**): | Scenario | Cold Storage | Auth Service | Hot Storage | iFrame | | ------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | [Scenario 1](/security/deployment-scenarios#scenario-1-fully-self-hosted) | **SH** | **SH** | **SH** | **SH** | | [Scenario 2](/security/deployment-scenarios#scenario-2-self-hosted-auth-cold-storage) | **SH** | **SH** | **TP** | **TP** | | [Scenario 3](/security/deployment-scenarios#scenario-3-self-hosted-hot-storage) | **TP** | **TP** | **SH** | **TP** | | [Scenario 4](/security/deployment-scenarios#scenario-4-self-hosted-cold-storage) | **SH** | **TP** | **TP** | **TP** | | [Scenario 5](/security/deployment-scenarios#scenario-5-self-hosted-auth-service) | **TP** | **SH** | **TP** | **TP** | | [Scenario 6](/security/deployment-scenarios#scenario-6-fully-hosted) | **TP** | **TP** | **TP** | **TP** | #### Scenario 1: Fully Self-Hosted | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **SH** | **SH** | **SH** | **SH** | > Fully self-hosted. This scenario relies on the hosting party implementing both the Authentication Service and the Hot Storage. Developers can *know* they're running unaltered builds of the Openfort components by checking the attestations, as explained in the [attestation section](/security/system-integrity#attestation). On that note, developers can enforce execution of this image by requiring attestations in their policies, as in this Google Cloud example that [requires attestation](https://cloud.google.com/binary-authorization/docs/key-concepts#evaluation-modes). Developers may also opt to go one step further and make some of their configuration public to show that attestation requirements are in place in their infrastructure. ##### Noteworthy Risks * **Execution environment**: The hardware and the OS running the storage services (and the DBs they rely on) have access to the processes' memory and could extract sensitive data such as key shares from the programs' memory. It is essential to run them in trusted, secure environments. For instance, when running the components in GKE make sure to use [confidential GKE nodes](https://cloud.google.com/kubernetes-engine/docs/how-to/confidential-gke-nodes). The underlying storage used by the DBs should also be protected, to prevent extraction and brute-forcing of stored keys. If using Cloud Hosts, encrypt storages with self-managed keys, such as GC's [CMEK](https://cloud.google.com/kubernetes-engine/docs/how-to/using-cmek). * **Communication**: Enforce and validate TLS encryption in every communication happening between two components. If services are running on the same machine, handle TLS certificates appropriately, use some proxy that supports TLS such as [Envoy](https://www.envoyproxy.io/), or use [Unix Domain Sockets](https://man7.org/linux/man-pages/man7/unix.7.html). The latter are still vulnerable to eavesdropping from the same machine, but have a reduced attack surface compared to TCP sockets. * **Total Asset Ownership**: Since the hosting party controls **all** the Keys components, they can also decrypt cold shares if those belong to a project they created. The hosting party has access to both encryption shares and to the encrypted cold shares stored in the cold storage. Thus, **projects must be registered and handled by third parties unrelated to the hosting party.** All of the next scenarios load their iframe from a third party, which could have been tampered with. This is not an issue in Scenario 1, as the iframe is loaded from the same origin as the rest of the components and served by the hosting party. #### Scenario 2: Self-Hosted Auth + Cold Storage | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **SH** | **SH** | **TP** | **TP** | Developers rely on a third party to host their hot storage and their iframe. The main risk here is the iframe being tampered with, allowing attackers to capture passwords and secrets on the client's side. As mentioned before, iframe builds are attested and those feature derived checksums of the static assets it provides. The iframe is meant to be called via RPC methods from another app, so it is possible for both developers and end users to verify if the obtained static assets' checksums match those provided by the official build logs. #### Scenario 3: Self-Hosted Hot Storage | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **TP** | **TP** | **SH** | **TP** | The greatest security concerns in this scenario are: * **Access token forgery**: the third party (TP) could forge access tokens in the name of the user, and use them to access the cold storage.\ Shares are still encrypted with user entropy, which brings us to the next point. * **Share or encryption key bruteforcing**: either by forging tokens or accessing cold storage directly, the third party could try to decrypt the encrypted keys through brute-forcing. Scenarios in which the party controlling the cold storage is also responsible for automatic recovery share keeping, password-based recovery provides more protection than automatic recovery since the Host has access to all the required entropy to decrypt the cold storage share. #### Scenario 4: Self-Hosted Cold Storage | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **SH** | **TP** | **TP** | **TP** | This is a common scenario: the implementations provided by Openfort are self-hosted, while the ones defined as unimplemented APIs are hosted by a third party such as Openfort. The biggest risk in this scenario is the third party forging access tokens in the name of the user, and accepting them from the hot storage; as they implement and control both. Unlike in Scenario 3, the hot shares are not encrypted with user entropy which makes them vulnerable to access token forgery. #### Scenario 5: Self-Hosted Auth Service | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **TP** | **SH** | **TP** | **TP** | In this scenario, the third party controls the cold and hot storages. Combined with the brute-force risk mentioned in Scenario 3, this can be enough to reconstruct the users' private keys. #### Scenario 6: Fully Hosted > Fully hosted by a third party, such as Openfort. | Cold Storage | Auth Service | Hot Storage | iFrame | | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | **TP** | **TP** | **TP** | **TP** | In this scenario, a single third party entity (Openfort) is responsible for all components. Because it hosts both the hot and cold storage, it is necessary to encrypt at least one of those two shares to prevent the host from accessing the full key. There are two safe approaches: 1. **User entropy**: only the user knows a password that is required to decrypt the cold storage share. 2. **Automatic recovery with OTP**: there is an encryption key; split between the user and the cold storage, that is used to encrypt and decrypt the cold storage share. The cold storage is temporarily granted access to the user's encryption share through a one-time access method, invoked by the user. The point of both approaches is the same: make user action a requirement to access the key. Making encryption key shares a one-time access thing in the cold storage, as well as the final key a one-time access thing in the iframe, has the objective of preventing key usage without user action. :::important[OTP Recommendation for Automatic Recovery] Third party Hosts that manage users' automatic recovery and the authentication service could, in theory, forge access tokens representing users and gain access to the recovery shares of those who have automatic recovery configured. Users should be made aware of this risk when configuring automatic recovery. **Using OTP is strongly recommended to protect user accounts in this scenario.** ::: Another important aspect to take into account when using automatic recovery is who owns what resources. If the organization in charge of the cold storage starts a project within it, the organization can reconstruct the project-wide encryption key on their own. Projects must be managed by someone who doesn't directly control the cold storage to avoid this scenario. ### Frequently Asked Questions #### Does the backend ever have access to enough shares to reconstruct a private key? **In automatic recovery mode without OTP: It depends on Hosting.** * **Scenario 6 (Fully Hosted by a Host such as Openfort): No.** The Host (such as Openfort) holds the *Shield Encryption Part*. You (Developer) hold the *Developer Encryption Part*. As long as the Developer does not expose their part to the Host, the Host cannot decrypt the Cold Share. The Host has 1 share (Hot) + 0 usable shares (Encrypted Cold) = 1 share. **Insufficient.** * **Scenario 1 (Fully Self-Hosted): Yes.** If you host everything yourself, you hold the Hot Share + Shield Encryption Part + Developer Encryption Part. You (the Host/Developer) have full custody. **In password/passkey recovery or automatic recovery with OTP: No.** * **Password recovery**: Cold share is encrypted with user's password (client-side); backend never has the plaintext * **Passkey recovery**: Cold share encryption key is derived client-side via PRF extension; backend never has access * **Automatic + OTP**: OTP verification is required, which requires active user participation that cannot be forged #### What prevents backend-side reconstruction in non-custodial deployments? | Protection Mechanism | How It Works | Backend Access Blocked? | | ------------------------- | ------------------------------------------------------------------------ | ----------------------------------- | | **Password Recovery** | User-provided password encrypts cold share client-side | ✅ Yes | | **Passkey Recovery** | PRF extension derives encryption key on user device | ✅ Yes | | **Automatic (Cloud)** | Encryption key split between Host (such as Openfort) and Developer (You) | ✅ Yes (if Developer Part is secure) | | **OTP/OTP for Automatic** | OTP sent to user's email/phone required for decryption | ✅ Yes | | **Split Hosting** | Hot and cold storage hosted by different, mutually-untrusting parties | ✅ Yes | #### Is the open-source configuration custodial by default? **Yes, if you host everything yourself.** In a fully self-hosted setup (Scenario 1): 1. You control Hot Storage. 2. You control Shield (Cold Storage). 3. You control the Developer Encryption Part. 4. \= You can decrypt everything. **To achieve non-custodial guarantees in Self-Hosted:** * Use **password-based recovery** (recommended for maximum security), OR * Use **passkey-based recovery**, OR * Enable **OTP/OTP verification** for automatic recovery, OR * Run Shield in a **TEE (Trusted Execution Environment)** where the encryption keys are not extractable even by you. :::note[MiCA and Custody Definitions] Under regulatory frameworks like MiCA, a platform that is technically capable of reconstructing or exporting a user's private key—even if gated by authentication and frontend flows—may be considered custodial. The open-source OpenSigner configuration, when using automatic recovery without OTP, meets this technical definition of custody because the platform can access both shares needed for key reconstruction. **For non-custodial regulatory classification:** Implement one of the protection mechanisms listed above to ensure the platform cannot unilaterally reconstruct user keys. ::: ## Security Overview The OpenSigner system is composed of multiple components that communicate over HTTP. Communication includes sensitive data such as key shares or access tokens, therefore **it is crucial to ensure communication is secured through TLS** and HTTPS is enforced. ### Glossary | Term | Definition | | ----------------------------- | -------------------------------------------------------------------------------------- | | **End User** | The final user of the wallet (for example, a player in a game). | | **Developer / Project Owner** | The entity building the application (for example, You). Controls the project secrets. | | **Host / Operator** | The entity operating the infrastructure (for example, Openfort or You in self-hosted). | | **Auth Provider** | The system validating user identity (for example, Openfort Auth, Google, Custom OIDC). | | **Shield (Cold Storage)** | Component that stores encrypted recovery shares. | | **Hot Storage** | Component that stores frequently accessed shares. | | **iFrame** | Client-side component running in the user's browser that reconstructs keys. | ### Trust Model Understand the root of trust at each point in the system. The key is split into three shares; two of them are enough to reconstruct the key. Once a user logs into a device, the three shares are stored in: * **User's device**: the domain-protected storage of the browser. * **Hot storage**: users can access this share through an access token granted by the **Auth Provider**. * **Cold storage** (Shield): users can access this share provided a request is made with valid entropy and a valid token is issued by the **Auth Provider**. The system relies on the following roots of trust: #### Auth Provider (Authentication Service) The **Auth Provider** is responsible for validating the user's identity and granting access tokens. It must be trusted to securely handle user credentials and issue tokens that can be used to access shares. While the authentication service API is provided, each implementation is tied to each project's specific needs, and needs to be done with care. The impact of a compromised authentication varies depending on the context: * If the attacker also has access to the user's device and the key was previously reconstructed there: * They can fully recover the secret because user authentication directly unlocks the hot share. * If the attacker only has the user's credentials but no device access, the outcome depends on how cold share recovery is handled: * With *user-based recovery*, an extra password or passkey (independent from the user's main credentials) is needed to decrypt the cold share. * With *automatic recovery*: * Without OTP: the attacker can easily recover the user's secret key just by using the stolen credentials. * With OTP: user interaction is required to recover the secret, as the attacker would need to compromise the OTP method as well. :::tip[Best Practice] The safest way to protect a cold share is to use user-based recovery with a completely separate password/passkey that's unrelated to the primary authentication method. ::: #### Application Logic Two out of three shares are enough to reconstruct the key. For this reason, the system includes audited, open-source implementations of the storage and management systems of two of the three shares. * **iFrame**: The iFrame stores shares (in domain-based browser storage) and handles splitting and reconstructing the keys. It is the only component that has access to the full, reconstructed key. * **Cold Storage**: The cold storage is the stepping stone for every login in a new device. The share it contains is encrypted with either user or project entropy. #### Environment Use trusted browsers, servers, and execution environments. #### Developer / Project Owner Users with **automatic recovery** trust the owner of the project (Developer) to hold the **Developer Encryption Part** securely. If the Developer loses this part, automatic recovery becomes impossible for registered devices. If the Developer exposes this part, the non-custodial guarantees of the **Host** are compromised. #### Transport Sensitive data, such as access tokens and key shares, travel over the network when transmitted from one component to another. It is vital to have and enforce secure communication channels, such as HTTPS, to prevent eavesdropping and tampering. Make sure to validate TLS certificates to prevent Man In The Middle (MITM) attacks. [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) should be used for browser-oriented operations. ### Next Steps * **[Custody Model FAQ](/security/deployment-scenarios#frequently-asked-questions)** - Key questions about backend reconstruction and non-custodial guarantees * [Recovery Methods](/security/recovery-methods) - Password, passkey, and automatic recovery options * [Deployment Scenarios](/security/deployment-scenarios) - Hosting configurations and their security implications * [Threat Analysis](/security/threat-analysis) - Potential attack vectors and mitigations ## Recovery Methods The three supported recovery methods are *automatic*, *password* and *passkey* based recovery. Each has different security considerations. Each refers to how the recovery share is encrypted, and how the user can access it. ### Password-Based Recovery In password-based recovery, the recovery share is encrypted with a user-provided password. Recovery share encryption and decryption happen in the iFrame, making the user the sole owner of the entropy guarding the share. Check out the [**password-based signup**](/actions/signup#user-with-password-recovery) section for more details. :::info[Encryption Details] When setting a recovery password, the iframe uses the open-source [@openfort-xyz/crypto-js](https://github.com/openfort-xyz/crypto-js) library to first derive a secure key. The key derivation algorithm is `Argon2` with `12` iterations using `64MiB` of memory and a `128` bit long salt. A `256` bit length encryption key is then derived and used to encrypt the cold share. The cold share is encrypted using `AES-CBC` with the previously derived key. The Initialization Vector (IV) is generated by the iframe and is `128` bits of length, too. ::: ### Passkey Recovery Passkey recovery works similarly to password-based recovery: an encryption key is derived from user input and shares are encrypted and split following the same pattern. In passkey-based recovery the cold share plaintext never leaves the client, neither does the derived encryption key. OpenSigner stores the following information: * The internal passkey ID, issued by the passkey authenticator * The encrypted (cold) share * Some environment-related information (browser name, OS, OS Version and Device information) :::info[Encryption Details] The cold share is encrypted using AES256 in CBC mode. The encryption key is derived using the Pseudo Random Function (PRF) extension, available for most modern passkeys. This extension allows an authenticated user to derive encryption keys. As in password based recovery, the IV is 128 bits of length and is generated by the iframe itself. ::: ### Automatic Recovery In automatic recovery, key encryption and decryption happen in the cold storage. While providing the benefit of not requiring the user to remember a password, it introduces some risks: #### Ownership Risk If the entity in control of the cold storage (which contains one of the two shares required to reconstruct the encryption key) is also the one in control of the other encryption key shares, it can access the full recovery share. This, when combined with control over one of the other two shares, allows the entity to reconstruct the key. #### Network Risk The raw encryption key travels over the network from the iFrame to the cold storage and can be intercepted. Mitigate this with proper network security measures. Check out the [**automatic recovery signup**](/actions/signup#user-with-automatic-recovery) section for more details. #### The 3 Key Shares vs The 2 Encryption Parts ##### System 1: Key Shares (Shamir's Secret Sharing) The **user's private key** is divided into 3 shares: 1. **Device Share**: Stored in the browser's localStorage/IndexedDB. 2. **Hot Share**: Stored in the Host's Hot Storage (such as Openfort). 3. **Cold Share**: Stored in the Host's Shield (Cold Storage). :::tip You need **2 of 3** shares to reconstruct the private key. ::: ##### System 2: Encryption Parts (Project Encryption Key) The **Cold Share is ENCRYPTED**. To decrypt it, you need the Project Encryption Key, which is also divided: 1. **Developer Encryption Part**: Held by the Developer (You). Created once and must be stored securely. 2. **Shield Encryption Part**: Stored in the Shield database (managed by the Host). :::tip You need **BOTH** parts to decrypt the Cold Share. ::: #### Non-Custodial in cloud hosting (such as Openfort) For a Cloud Host (like Openfort) to reconstruct a user's private key, it would need 2 shares. * It has the **Hot Share** (1/3). * It has the **Cold Share** (2/3), **BUT** it is encrypted. * To decrypt the Cold Share, it needs the **Developer Encryption Part**, which **only the Developer holds**. Therefore, the Cloud Host cannot decrypt the Cold Share and remains with only 1 usable share (Hot Share), which is insufficient to reconstruct the private key. #### Secure Usage: Encryption Sessions To avoid sending the **Developer Encryption Part** with every request (which would verify the "custody" rule but increase exposure risk), use **Encryption Sessions**. 1. Backend calls `POST /project/encryption-session` with the `encryption_part`. 2. Shield returns a temporary, one-time use `session_id`. 3. The iFrame uses this `session_id` to decrypt the Cold Share. 4. The session expires immediately after use. This ensures the critical secret (Developer Part) is not constantly exposed on the network. :::info[Encryption Details] The cold storage implementation generates an encryption key for the recovery share, splits it into three (one is kept by the cold storage, one given to the caller, the last one deleted), then uses AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) to store one of the shares in a database. The nonce, or IV, is 96 bits of length in this case, as recommended for this particular mode of AES. As in Password Recovery Mode, 256-bit length keys are used. This key is generated and split in shield. The key is generated using a secure RNG (golang's `crypto/rand`). ::: ### OTP Verification for Automatic Recovery :::warning[Critical Security Consideration] In automatic recovery without OTP, the backend has access to both hot storage and cold storage. Using a valid user JWT, the system can coordinate access to these shares and reconstruct a user's private key on the backend. **No additional user-held secret is required beyond standard user authentication.** ::: #### Why OTP is Essential To enhance the security of automatic recovery, you can enable OTP verification for your Shield project. When enabled, Shield requires an OTP to create an encrypted session for share decryption. The OTP is sent to the user's contact information (either email or phone number). This reduces the control that the cold storage host has over the stored shares. #### How OTP Prevents Backend Reconstruction | Scenario | Backend Can Reconstruct Key? | User Action Required? | | ---------------------------------- | ---------------------------- | --------------------------------- | | Automatic recovery **without OTP** | ✅ Yes | ❌ No | | Automatic recovery **with OTP** | ❌ No | ✅ Yes - must provide OTP | | Password-based recovery | ❌ No | ✅ Yes - must provide password | | Passkey-based recovery | ❌ No | ✅ Yes - must authenticate passkey | #### Enabling OTP When OTP is enabled: 1. User initiates a recovery/signing operation 2. Shield sends an OTP to the user's registered email or phone 3. User provides the OTP to the iframe 4. Only after OTP verification can the cold share be decrypted This ensures that even with valid authentication tokens, the backend cannot unilaterally access user keys without active user participation. ## System Integrity This page covers how to verify that OpenSigner components are authentic and haven't been tampered with. ### Attestation Openfort's public images are attested through [Cosign](https://github.com/sigstore/cosign). Github generates a unique key for each workflow run, and signs the images built by the workflow. Openfort has no access to these keys, ensuring the signed builds have not been tampered with or created with any method other than the automated release workflows. #### Verifying Images To validate that an image was built, published, and signed by the Openfort CI workflows, users can run: ```bash gh attestation verify \ oci://: \ --repo '' \ --signer-workflow ' /' ``` #### Example: Verify Shield Image ```bash gh attestation verify \ oci://docker.io/openfort/shield@sha256:61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c \ --repo openfort-xyz/shield \ --signer-workflow openfort-xyz/shield/.github/workflows/docker-image.yml ``` #### Verify by Tag Although digests are the recommended way to refer to images, images can also be verified by tag: ```bash gh attestation verify \ oci://docker.io/openfort/shield:v0.2.6 \ --repo openfort-xyz/shield \ --signer-workflow openfort-xyz/shield/.github/workflows/docker-image.yml ``` #### Alternative: Rekor Logs Alternatively, images can be verified by manually checking the Rekor logs and searching for the image digest, for example: [https://search.sigstore.dev/?hash=61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c](https://search.sigstore.dev/?hash=61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c). ### Derived Checksums The iframe serves static assets, and checksums for these assets are generated at build time using SHA-256. Because iframe builds are attested, these checksums are also transitively attested. The published checksums can be compared to those computed by the user after downloading the static assets. If any mismatch is found, the iframe's execution can be aborted. #### Example Build Output Here's an example of the output generated when building the iframe: ``` 876766b4e80133fd490603e073d3567425b88794828a9292104244c9e40875ed /usr/share/nginx/html/50x.html 78fe0c953e0235a6ce563d728eaacbb9a4630cbd22831523d74017820a5c067c /usr/share/nginx/html/index.html b209a972c9f0dcc4354098df2943d21b0daa6a49486c07f2cd265d6274b0f3c2 /usr/share/nginx/html/assets/index-jgtWx_p5.js 713b113fde9db05faa5b320e52ed7a5f0693faa71262ad55760d65b062103bc7 /usr/share/nginx/html/favicon.ico ``` This means that the user should expect having only one javascript file called `index-jgtWx_p5.js` whose `sha256sum` is `b209a972c9f0dcc4354098df2943d21b0daa6a49486c07f2cd265d6274b0f3c2`, which can be verified client-side anytime. This is also an exhaustive list of all the assets the user should expect seeing on their side. #### Limitations :::danger[Important Caveat] This checksum does not prove that the `iframe` is intact. The `iframe` still relies on an nginx to provide such static assets and it's still on the client to retrieve and check those hashes. Attackers can still divert traffic to unwanted places or load additional assets not covered by the build-time checksum generation. ::: #### Client-Side Verification To verify checksums client-side: 1. Fetch the static assets from the iframe origin 2. Compute SHA-256 hash of each file 3. Compare against the published build checksums 4. Abort iframe execution if any mismatch is detected ```javascript // Example verification approach async function verifyAssetChecksum(url, expectedHash) { const response = await fetch(url); const buffer = await response.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hashHex === expectedHash; } ``` ## Threat Analysis This section covers potential attack vectors against OpenSigner components and recommended mitigations. ### Tampering Risks If the following components are tampered with by a third party or compromised while under the developer's control, these issues can arise: #### iFrame Compromise The iframe is the only component with access to the full, reconstructed key. If the iframe is compromised, the attacker could use the key to impersonate the user and interact with the chain on their behalf. **Mitigations:** * Verify iframe checksums against official build logs * Use [attestation verification](/security/system-integrity#attestation) to ensure build integrity * Load iframe from trusted, self-hosted origin when possible #### Auth Service Compromise Having a compromised auth service has, besides the usual implications, some risks *if the storages are also compromised*. The auth service could forge an access token and have the hot or cold storage accept them. Users could then attempt to perform an operation unaware of the forgery, and provide their recovery share entropy to the attacker when trying to log into a new device. **Mitigations:** * Use short-lived access tokens with strict validation * Implement token binding to specific operations * Monitor for unusual authentication patterns #### Hot Storage Compromise If the hot storage is compromised or tampered with, attackers have access to one of the two shares required to reconstruct the key. **Mitigations:** * Hot storage alone is insufficient for key reconstruction * Requires compromise of cold storage or device share for full attack * Encrypt database storage with self-managed keys (CMEK) #### Cold Storage Compromise The risk of a compromised cold storage is, in isolation, lesser than that of the hot storage because the cold storage share is encrypted with user entropy, and access to the cold storage alone is not enough to reconstruct the recovery share. When combined with other compromised components, the risk increases significantly. **Mitigations:** * Use password or passkey recovery (user-held secrets) * Enable OTP for automatic recovery * Run cold storage in TEE with non-extractable KMS ### Attack Scenarios #### Credential Theft + No Device Access **Attack:** Attacker obtains user's login credentials but doesn't have physical access to their device. | Recovery Method | Outcome | | ------------------------------- | -------------------------------------------------------------------------- | | Password-based | ❌ Attack fails - attacker needs recovery password | | Passkey-based | ❌ Attack fails - attacker needs passkey device | | Automatic (Self-Hosted, No OTP) | ⚠️ Attack succeeds - credentials unlock all shares if Admin holds all keys | | Automatic (Cloud) | ❌ Attack fails - Admin holds Developer Part, attacker lacks it | | Automatic (with OTP) | ❌ Attack fails - attacker needs OTP | #### Malicious Host **Attack:** The entity hosting OpenSigner components attempts to access user keys. | Hosting Configuration | Outcome | | --------------------------------------------------------------- | ------------------------------------ | | Single Host operates all + holds ALL keys (Self-Hosted default) | ⚠️ Host can reconstruct keys | | Cloud Host (such as Openfort) + Developer holds Encryption Part | ❌ Host has only 1 usable share (Hot) | | Single Host + password/passkey recovery | ❌ User entropy protects cold share | | Single Host + automatic + OTP | ❌ OTP required for cold share access | | Split hosting (different Hosts) | ❌ No single party has both shares | #### Token Forgery **Attack:** Auth service operator forges tokens to access shares. **Protection:** Even with forged tokens: * Password recovery: cold share requires user's password * Passkey recovery: cold share requires user's passkey * Automatic + OTP: cold share requires user's OTP ### Best Practices * **Always validate tokens**: expiration, issuer, and contents. * **Don't log sensitive data**, such as access tokens or key shares. * **Enforce valid TLS** on all communications. * **Run services in Trusted Execution Environments (TEE)** when possible. * **Set TTLs for access tokens** to limit their validity period. * **Use password or passkey recovery** for maximum security. * **Enable OTP for automatic recovery** to prevent backend reconstruction. * **Separate project ownership** from infrastructure hosting. * **Monitor audit logs** for unusual access patterns. * **Regularly rotate** service credentials and API keys. import { SVGSequence } from '../../lib/svgsequence.tsx'; ## Introduction ### Overview One of the most notable challenges in security is ensuring that a user—and *only* that user—has **continued** access to a secret, regardless of device loss, app reinstallation, or other life events. Traditional approaches often burden the user with managing backups of their secret keys. These keys are long, complex, and difficult to store securely. This complexity often leads users to choose less secure alternatives rather than navigating the secure platform, especially when migrating to a new device. Various solutions have attempted to address this: * **Seed phrases**: Easier than random characters but still difficult to remember and secure. * **Passkeys**: Eliminate the need for memory but can be tricky to transfer between devices and raise backup concerns. * **Password-based encryption**: Vulnerable to brute-force attacks if the password is weak. * **HSMs (Hardware Security Modules)**: Used by companies like Apple, [WhatsApp](https://engineering.fb.com/2021/09/10/security/whatsapp-e2ee-backups/), and Signal. They protect PINs from brute-force attacks but rely on the hardware's security and can be expensive and inflexible. ### Introducing OpenSigner OpenSigner is designed to solve these problems by enabling: * **Users** to: * Securely store cryptographic keys by splitting them into shares stored in separate locations. * Sign data using an ephemeral reconstruction of their private key, used only for a specific operation. * **Organizations and Developers** to: * Manage their users' cryptographic keys. * Abstract the key recovery process via `automatic recovery` or empower users with `password recovery` or `passkey recovery`. #### Architecture ![Component overview](/diagrams/components.svg) OpenSigner consists of three main components: 1. **iFrame**: Performs user operations, handles key splitting, and reconstructs keys. 2. **Key Share Storage**: Keys are split into three shares, stored in different locations: * **User device**: Stored within the iFrame on the user's device. * **Hot storage**: External storage for frequently accessed shares. * **Cold storage**: External storage for shares accessed only when a local or hot share is missing. 3. **Authentication Service**: Verifies user access to shares by issuing access tokens to the iFrame and exposing validation endpoints to the storages. ### How it works #### Splitting OpenSigner uses [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing) to split private keys into three shares. This ensures the key is never stored in a single location, reducing the risk of compromise. ![Splitting overview](/diagrams/sss_splitting.svg) #### Reconstruction The original key can be reconstructed using any two of the three shares. The iFrame component reconstructs the private key only when needed and discards it immediately after use. ## Getting Started After starting the components (for example, with `make run`), you can start using the OpenSigner service. Open the iFrame in your browser (running on port 7050 by default) or explore the [Postman collection](/apis/postman). ## Setup The project Makefile builds and runs all components through docker-compose. Building the images takes time, particularly the Better Auth component used for authentication service database migrations: [`@better-auth/cli`](https://www.npmjs.com/package/@better-auth/cli). First, clone the project: ```shell git clone https://github.com/openfort-xyz/opensigner.git ``` To build the containers, run: ```shell make build # or `make clean build` to remove old images and volumes ``` To run them, use: ```shell make run ``` The components can be configured through environment variables, check out the `env.example` and the `docker-compose.yml` files for a list of available variables. Once you have everything running, head over to the [Getting Started](/introduction/getting-started) guide. ## Users The OpenSigner wallet key management components share one `user` concept, except for cold storage which expands on it. One user can have none, one, or many keys for a chain. One user can have keys for multiple chains. Keys are not shared across chains. ### Projects When using automatic key recovery, cold storage users rely on projects. Projects manage the entropy for the users' recovery shares. More specifically, projects have access to half of the entropy, while the cold storage has the other half. Projects are containers for users' keys and their recovery share entropy. Projects are given unique API keys (`X-API-Key`), used by clients to identify them. Read more about cold storage authentication in the [Cold Storage](/components/shield) documentation. ### Providers Providers are different ways to authenticate a user in a project. A project may identify its users via the [Openfort](https://openfort.io) auth system or by a custom provider. The same project can enable both authentication methods. In any case, a project can have at most one custom provider, and at most one openfort provider. It also needs at least one valid authentication provider registered and set up. Whenever a request is performed, Shield knows which authentication provider it should use based on the contents in the `X-Auth-Provider` header. This header accepts two values: `openfort` and `custom`. Shield identifies to which project users belong via the `X-API-Key` header, which maps them to their project. Users themselves are mapped using personal keys/tokens. How these keys/tokens look depends on what kind of provider they're using to authenticate. #### Openfort Provider The Openfort provider relies on the user's Openfort `publishable_key` to properly identify and authenticate its users. #### Custom Providers A custom provider is an external source in charge of authenticating users. Custom providers rely on externally signed `JWT` tokens to **identify and authenticate users**. This means that all keychains and keys created by them are tied to their particular user ID. When a project defines a custom provider, Shield uses the JWT tokens issued by it to identify and authenticate users. Custom providers consist of the following fields: * `jwk_url` A URL pointing to a publicly exposed JWK keyset (usually `.well-known/jwks.json`) * `pem_cert` A PEM file containing the **public** key from the key pair used to sign JWT tokens * `key_type` The type of the key pair used to sign and validate tokens. Supported types are `RSA`, `ECDSA`, and `Ed25519` Both `jwk_url` and `pem_cert` can be specified. At least one is required. ## Authentication The authentication service is responsible for verifying users. The auth service, hot storage, and cold storage all share the `user` concept. A user is a `uuid`, and the owner of the data it has stored in the storages. When a user requests data from the storages, it must pass its user ID and access token. The storages then ask the auth service to verify that the access token belongs to the user, and only then return the requested data. The authentication service supports two types of authentication: * **First-party authentication**: The authentication service provided by Openfort. * **Third-party authentication**: An authentication service provided by an external provider such as Google, Apple, or GitHub. In both cases, the hot and cold storages expect a JWT token to be passed in the `Authorization` header or in a cookie field specified on the request itself. ### First-party authentication In this model, the implementer is fully responsible for user authentication. The current model uses email/password authentication. ### Third-party authentication Third-party authentication relies on OAuth 2.0 or OpenID Connect to verify user identities. Once the user authenticates, the third-party provider returns an access token. Rather than giving this token directly to users, implementers should map it to a new token generated by the authentication service (not the third party) and pass this token instead. This ensures users have access to the Keys service but can't impersonate the auth service. ## Hot Storage The Hot Storage component is used to store "hot shares": shares that are required each time an operation is performed with the private key the "hot share" belongs to: log in, sign transactions, export the private key, and more. Unlike the [Cold Storage](/components/shield) component, which is only accessed when the user logs into a new device, the hot storage handles fast, frequent access. The hot storage doesn't include a production-ready implementation. A base implementation for development purposes is available under the `hot_storage/sample` directory. Implement your own version according to your needs. The sample implementation is written in Go, uses PostgreSQL to store data, and can be configured through the environment variables shown in the `docker-compose.yml` file at the root of the repository. ### How it works The Hot Storage links shares to a specific device, user, and auth provider, and stores them in a database. The user is validated against the specified auth provider using the configured [Auth Service](/components/auth). Users must specify the user ID, auth provider, and device ID when requesting shares, and prove their identity through a [JWT](https://www.jwt.io/) token issued by the specified auth service. The auth service must match the one configured when creating the share. Hot shares are not encrypted with user entropy, so it is important to ensure that the database is secure and access is controlled. Follow [best practices for database security](https://www.cybertec-postgresql.com/en/postgresql-security-things-to-avoid-in-real-life/). ### Specification The full specification for the request is available in the [API documentation](/apis/hot_storage), and a Postman collection with pre-configured calls is available at the [Postman Collection](/apis/postman). ## iFrame The iFrame is the core client-side component that handles all cryptographic operations securely within the user's browser. ### General overview The iFrame is embedded into the user browser, or into a React Native app. It is the component in charge of generating the private key, splitting it into shares, and storing them in their respective storage components; as well as fetching the shares and reconstructing the private key when required. Operations that use the private key all take place inside the iFrame, so the private key is never exposed to the outside world. The iFrame reconstructs the private key in memory and forgets it after each usage, ensuring that the private key is never stored in the browser and its in-memory lifetime is as short-lived as possible. The sample provided in the [iFrame sample](https://github.com/openfort-xyz/opensigner/tree/main/iframe/sample) shows how to: * log into the auth service to get an access token * configure the cold storage * create an iFrame instance * register a user, generating its private key to split it and store the shares * retrieve the shares, reconstruct the private key and sign a message ### How it works The iFrame is written in React and uses the [crypto-js](https://github.com/openfort-xyz/crypto-js), [openfort-js](https://github.com/openfort-xyz/openfort-js), and [shield-js](https://github.com/openfort-xyz/shield-js). The constructor expects the access token and the URL of the hot storage. The iFrame can be interacted with by using the browser [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) API, which allows sending messages to the iFrame and receiving messages from it. The cold storage URL is configured when calling the methods that require it. After a successful reconstruction of the private key, if the current device had no local share in the [browser storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), the key is split once again into *different* shares which are then stored in the local and hot storages. ## 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](https://github.com/openfort-xyz/shield) `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](https://csrc.nist.gov/pubs/sp/800/132/final) and [at least 600\_000 iterations should be used along with PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#:~\:text=If%20FIPS%2D140%20compliance%20is,provides%20no%20additional%20secure%20characteristics). `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](/components/cold_storage/otp) 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. ## OTP for Automatic Recovery Shield supports One-Time Password (OTP) verification to add an additional layer of security when creating encrypted sessions. :::info OTP is available only for shares with automatic recovery method. ::: ### Enabling OTP OTP is a project-level feature that must be enabled before it can be used. Once enabled, **OTP cannot be disabled** for a project. To enable OTP for a project, use the following endpoint: **Endpoint:** `POST /project/enable-2fa` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Response:** * `200 OK`: OTP enabled successfully * `409 Conflict`: OTP already enabled for this project ### How OTP Works When OTP is enabled for a project, users must go through an OTP verification flow when creating an encrypted session. Here's the typical flow: #### 1. Request OTP Before creating an encrypted session, users must request an OTP code. **Endpoint:** `POST /project/otp` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Request Body:** ```json { "user_id": "user_external_id", "email": "user@example.com", "dangerously_skip_verification": false } ``` **OR** ```json { "user_id": "user_external_id", "phone": "+1234567890", "dangerously_skip_verification": false } ``` **Parameters:** * `user_id` (required): The external user ID * `email` (optional): User's email address to receive OTP via email * `phone` (optional): User's phone number to receive OTP via SMS * `dangerously_skip_verification` (optional, default: false): If set to `true`, skips OTP verification **Note:** You must provide either `email` or `phone`, but not both. **The `dangerously_skip_verification` Flag:** This flag can be used to simplify onboarding for new users. For example: * When **creating a new wallet**: Set this flag to `true` to skip OTP verification and streamline the signup process * When **recovering an existing wallet**: Set this flag to `false` to require OTP verification for additional security When this flag is set to `true`, an OTP is generated but not sent to the user, and the OTP verification step can be skipped when creating an encryption session. **OTP Delivery Methods:** Shield supports two delivery methods for OTP codes: 1. **Email OTP**: When an email address is provided, the OTP is sent to the user's email 2. **SMS OTP**: When a phone number is provided, the OTP is sent via SMS to the user's phone **Response:** * `200 OK`: OTP generated and sent successfully #### 2. Create Encryption Session with OTP After receiving the OTP, users create an encrypted session by providing the OTP code. **Endpoint:** `POST /project/encryption-session` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Request Body:** ```json { "encryption_part": "encryption_part_value", "user_id": "user_external_id", "otp_code": "123456789" } ``` **Parameters:** * `encryption_part` (required): The encryption part for the project * `user_id` (required): The external user ID * `otp_code` (optional): The OTP code received via email or SMS. Required if `dangerously_skip_verification` was `false` **Response:** ```json { "session_id": "generated_session_id" } ``` The `session_id` can then be used with the `X-Encryption-Session` header when registering, updating, or retrieving shares. ### OTP Security Features * **OTP Verification**: When OTP is enabled and `dangerously_skip_verification` is `false`, users must provide a valid OTP code to create an encrypted session * **Contact Verification**: Shield verifies and stores hashed contact information (email or phone) to ensure consistency across requests * **Rate Limiting**: Project-level rate limits prevent abuse of OTP generation * **Session Expiry**: Encryption sessions are time-limited for security ### Example Workflows #### New User Signup (Skip Verification) ``` 1. POST /project/otp with dangerously_skip_verification: true 2. POST /project/encryption-session (no OTP code needed) 3. Use session_id to register shares ``` #### Existing User Recovery (With Verification) ``` 1. POST /project/otp with dangerously_skip_verification: false 2. User receives OTP via email or SMS 3. POST /project/encryption-session with OTP code 4. Use session_id to retrieve shares ``` ### 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 | import Warning from './ui_warning.mdx' import SwaggerViewer from '../../lib/swaggerviewer.tsx' ## Authentication Service API import Warning from './ui_warning.mdx' import SwaggerViewer from '../../lib/swaggerviewer.tsx' ## Cold Storage API ### 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 ```json { "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 | #### Share Errors | HTTP Status | Error Code | Message | | ----------- | -------------- | -------------------- | | 404 | `SH_NOT_FOUND` | Share not found | | 409 | `SH_EXISTS` | Share already exists | #### 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 | | 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 | #### General Errors | HTTP Status | Error Code | Message | | ----------- | --------------------------- | ---------------------------- | | 500 | `INTERNAL` | Internal error | | 500 | `MISSING_NOTIFICATION_SERV` | Missing notification service | | 400 | `BAD_REQUEST` | Various messages | import Warning from './ui_warning.mdx' import SwaggerViewer from '../../lib/swaggerviewer.tsx' ## Hot Storage API ## Postman collection A Postman collection is available for testing the components. It runs against the service created with the `make clean build run` command. Download the [Postman collection](https://github.com/openfort-xyz/opensigner/blob/main/docs/public/postman/keys.json). :::warning Swagger UI has display inconsistencies with the dark page theme. Use the light theme to view the API definition. ::: ## Recovering a Key Before recovering a key, the user must call hot storage to retrieve their list of accounts and select the one to recover. Once selected, pass the account UUID to the iFrame, which handles the recovery process. The process differs depending on the recovery method: * **Password Recovery**: User provides a password to decrypt the cold share * **Automatic Recovery**: Cold share is decrypted server-side using project entropy * **Passkey Recovery**: User authenticates with a passkey to derive the decryption key ### Password Recovery The user recovers the key through the iFrame. The iFrame attempts to reconstruct the key and fails because the local share is missing. This share is stored on each device after the user recovers it for the first time on that device. Instead, the iFrame fetches the hot and cold shares with the JWT token it obtains from the auth service, reconstructs the key, splits it again, and: * Discard the cold share. * Store the local share in the device. * Store the hot share in the hot storage. The diagram below shows this process in detail. ![Login user with password-based recovery](/diagrams/login_new_device_password.svg) ### Automatic Recovery :::info `Admin` and `User` can be the same entity, though this defeats the purpose of automatic recovery. Typically, `Admin` is the application developer, and `User` is the end user. ::: ![Login user with automatic recovery](/diagrams/login_new_device_automatic.svg) The user can now use this device without accessing the cold storage again by using the local and hot shares to reconstruct the private key. The diagram doesn't show the private key reconstruction in the cold storage. The following section explains it in detail. The cold storage has the cold share, but it is encrypted with a key it has no access to. The key used to encrypt the cold share was split into shares and deleted after its first usage. The cold storage kept one of these shares, while the admin kept the other share. When the admin calls the cold storage `/v2/devices/register` endpoint, it provides its share as a one-time input for reconstructing the encryption key and decrypting the cold share. To enforce this one-time usage, the cold storage deletes the encryption key share passed by the admin after using it once. ![Cold share reconstruction](/diagrams/enc_key_reconstruction.svg) #### OTP with Automatic Recovery The flow is the same as with usual automatic recovery above, the only difference is in encrypted session creation - it requires some actions from user. The diagram below shows only the encrypted session creation flow. ![Login user with automatic recovery and OTP](/diagrams/login_new_device_automatic_otp.svg) As shown in the diagram, the admin must request an OTP for the user before proceeding with encrypted session creation. If a session is created without the OTP, the cold storage does not return the share to the iFrame, causing the entire key recovery process to fail. ### Passkey Recovery When cold shares are encrypted using passkeys, OpenSigner stores the necessary information for it to know which passkey it should ask for. If a user wants to retrieve their cold share, they are prompted to authenticate with the passkey they used to create the account. Most passkey authentication providers still show some kind of prompt even if they don't find the passkey within the local authenticator, such as a picture with a QR code if the passkey was created using a phone. Once properly authenticated, no further interaction is required from the user: both the PRF generation and the key derivation/share encryption happen under the hood, leaving the unencrypted cold share available for full key recovery. ## Use the Key This section covers the signing use case. After registering and logging into a device, the user can reconstruct their private key in the iFrame and use it to sign data. :::tip Coming soon ::: ## Creating a Key To create a new key with OpenSigner, users call the `create()` method on the iFrame. The iFrame generates a new private key and splits it into three shares using threshold cryptography. Two shares are distributed to hot storage and cold storage respectively, while the third share is stored locally in the localStorage. The signup process depends on the recovery method: * **Password Recovery**: User provides a password to encrypt the cold share client-side * **Automatic Recovery**: Cold share is encrypted server-side using project entropy * **Passkey Recovery**: User creates a passkey to derive an encryption key for the cold share :::info The signup diagrams show data being moved inside the process or to local storage with continuous lines, and the data potentially being transmitted over a network with dashed lines. Connection legend ::: ### Password Recovery When using password-based recovery, the user provides the entropy used to encrypt the recovery share. This ensures the system remains non-custodial and users control their keys. ![Sign up user with automatic recovery](/diagrams/signup_password.svg) ### Automatic Recovery When using *automatic recovery*, the entropy is managed by the cold storage service. To secure the recovery share, an encryption key is generated in the cold storage, which is then split into 2 shares with a required quorum of 2 for reconstruction. One share is kept by the cold storage, and another one is given back to the developer. The developer must secure this encryption share at all times, and it should never be exposed on the client side. When a request to secure a new recovery share is made, the developer must `POST` to the cold storage `/project/encryption-session` endpoint with the **encryption share**. This endpoint returns an encryption session ID, which the developer must provide to the user during the signup process. This session ID is valid for one-time use. This adds complexity but allows users to recover their keys without remembering a password. For the system to remain non-custodial, the **Developer** (holding the encryption part) must differ from the **Cold Storage Host**. :::danger If the same entity controls the cold storage and the **Developer Encryption Part**, the system becomes custodial, as the entity can access the recovery share. ::: ![Sign up user with automatic recovery](../../diagrams/signup_automatic.svg) #### OTP with Automatic Recovery You can enable OTP verification for your Shield project to enhance the security of automatic recovery shares. The diagram above remains valid. The key difference is that during key reconstruction, Shield requires an OTP when creating a new encrypted session. The OTP is sent to the user via SMS or email. This ensures that the cold share cannot be accessed for key reconstruction without user interaction. ### Passkey Recovery OpenSigner uses the passkey Pseudo Random Function (PRF) extension to derive an encryption key to symmetrically encrypt/decrypt the cold share. The user needs only to follow their authenticator's flow for passkey creation and validation. OpenSigner remembers which passkey it should ask for whenever a user wants to recover their cold share. Both the passkey's private key and the cold share are safe in this scenario, too: * The passkey's private key cannot leave the authenticator device * The cold share is encrypted and decrypted on the client side