CLI

Getting Started

DeepSecret is an end-to-end encrypted secret sharing platform built on post-quantum hybrid cryptography. Secrets are encrypted on your device before they reach the server. Only the intended recipients can decrypt them.

To get started you need three things. Before You Start covers your recipients, servers and storage too.

  1. Authentication. An API key from the API Keys page, or browser SSO via secret login.
  2. The CLI, installed and configured.
  3. A cryptographic identity (key bundle) generated on your device.

Before You Start

What each person needs before the first secret changes hands.

  • An account. A Google account with a verified email address. Signing in creates your DeepSecret account; there is no separate password.
  • A workspace. A Personal workspace, or a seat in an organization. Either starts with a 7-day free trial and no card. An organization verifies its domain with a DNS record before it subscribes, and its members sign in with addresses on that domain.
  • The CLI or the SDK. The secret CLI on macOS or Linux (Install), or the Go SDK in your own code. Encrypting and decrypting happen there. The web app manages your account and shows each secret's metadata, but it can't decrypt.
  • Your keys. secret identity generate, once on each device (Create Identity). Add --password or --security-key to seal the key file; without either it is protected only by its file permissions.
  • Storage. Nothing to set up: every workspace starts with DeepSecret Managed Storage, 5 GB during the trial. To use your own bucket, add it in Organization › Storage with an HTTPS endpoint on a public address and credentials that can put, get and list objects and run multipart uploads (and delete them, for hard delete). DeepSecret checks it by writing and reading a test object; then make it your primary storage. No CORS setup is needed.

For your recipients

Each recipient needs their own account, an active workspace to download (a plan, a trial, or a seat in yours) and keys from secret identity generate, because a send encrypts for their primary key. Someone outside your organization can be found only when both workspaces and both people allow external sharing, which is on by default (Sharing and Auto-Join). They take none of your seats.

For servers and AI agents

There are no separate machine accounts. Give a server or an agent its own account, signed in once by a person with a Google address (on your domain, to join your organization), then create an API key for it in Account › API keys and set DS_API_KEY. In an organization it takes a seat like any member. A script that uses a person's API key acts as that person and takes no extra seat. Only the device holding an account's primary key can decrypt new secrets, so give each server that receives secrets its own account.

A Complete Handoff

Alice, an engineer at Acme, sends a production config file to Maya, a contractor with her own Personal workspace. Alice is on Acme's Organization plan; Maya is on a Personal plan or its trial and takes none of Acme's seats. Both have the CLI installed, and both workspaces allow external sharing, the default.

  1. 1

    Maya signs in and makes her keys, once

    secret login
    secret identity generate --password

    The keys are made on Maya's laptop. Only the public keys reach DeepSecret, and until they do, there is nothing to encrypt for.

  2. 2

    Alice signs in and makes her keys, once

    secret login
    secret identity generate --security-key --backup
  3. 3

    Alice checks Maya's key

    secret identity verify maya@contractor.dev

    They read the safety numbers to each other on a call. If they match, Alice marks Maya's key verified. Skipping this means the first send trusts the key DeepSecret lists for Maya (Security Model).

  4. 4

    Alice encrypts and sends, on her laptop

    secret encrypt prod.env -t maya@contractor.dev --ttl 7d --ip 198.51.100.24

    The file is compressed, encrypted with a fresh key and signed on Alice's laptop. The key is wrapped for Maya's primary key alone. DeepSecret stores the ciphertext, the signed manifest and Maya's key wrap.

  5. 5

    Maya downloads and decrypts, on her laptop

    secret decrypt 64de9937-d067-4026-beb3-28bf7bd77bc0 --dir ./acme

    While the 7 days run and her request comes from 198.51.100.24, DeepSecret gives Maya the manifest, her key wrap and a download link. Her laptop checks Alice's Ed25519 and ML-DSA-65 signatures, unwraps the key and decrypts. Plaintext exists only on the two laptops.

  6. 6

    Afterwards: it expires, or Alice revokes Maya

    secret revoke 64de9937-d067-4026-beb3-28bf7bd77bc0 -t maya@contractor.dev

    DeepSecret stops handing out Maya's key wrap and download links. The copy Maya already saved stays on her laptop (Expiry, Deletion and IP Lock).

Install

The CLI is one binary, secret, for macOS, Linux and Windows on arm64 and amd64 (x86_64). GitHub Actions builds each release from its vX.Y.Z tag in the open source repository and publishes it on the releases page, where links under releases/latest/download/ always give the latest release.

One command

The installer picks the right build for your computer, checks that it downloaded intact against the release's checksums, and installs it. On macOS and Linux:

curl -fsSL https://github.com/deepsecret-io/cli/releases/latest/download/install.sh | bash

It installs secret in /usr/local/bin, asking for your password only if that folder needs it. Put INSTALL_DIR=~/.local/bin before bash to install somewhere else, or add a version after bash -s --. With cosign installed, it also checks that the release workflow signed the checksums.

On Windows, in PowerShell:

irm https://github.com/deepsecret-io/cli/releases/latest/download/install.ps1 | iex

It installs secret.exe in %LOCALAPPDATA%\Programs\DeepSecret and adds that folder to your PATH, with no administrator rights. Open a new terminal, then check it with secret --version.

By hand

To check the signature yourself, or to install where the installer cannot, pick the archive for your platform:

  • macOS, Apple silicon · secret_darwin_arm64.tar.gz
  • macOS, Intel · secret_darwin_amd64.tar.gz
  • Linux with glibc (Ubuntu, Debian, Fedora and most others), with security key support · secret_linux_amd64_fido2.tar.gz or secret_linux_arm64_fido2.tar.gz
  • Any Linux, Alpine and other musl systems included, fully static, without security key support · secret_linux_amd64.tar.gz or secret_linux_arm64.tar.gz
  • Windows, without security key support · secret_windows_amd64.zip or secret_windows_arm64.zip (Windows on Arm)
  1. 1

    Download the archive and the checksums

    curl -fsSLO https://github.com/deepsecret-io/cli/releases/latest/download/secret_darwin_arm64.tar.gz
    curl -fsSLO https://github.com/deepsecret-io/cli/releases/latest/download/SHA256SUMS

    Put your platform's file name in the first command. macOS quarantines files a browser downloads, and these binaries are not notarized, so download with curl, or run xattr -d com.apple.quarantine secret on the unpacked binary once.

  2. 2

    Check it against the checksums

    shasum -a 256 --check --ignore-missing SHA256SUMS

    On Linux:

    sha256sum --check --ignore-missing SHA256SUMS

    It prints OK next to the archive. Anything else means a damaged or altered download: do not run it.

  3. 3

    Install it on your PATH

    tar -xzf secret_darwin_arm64.tar.gz
    sudo mkdir -p /usr/local/bin
    sudo install -m 755 secret_darwin_arm64/secret /usr/local/bin/secret

    Each archive holds a folder of the same name with secret and its LICENSE.

  4. 4

    Check the version

    secret --version

    It names the release, its commit and the SDK version it was built with.

On Windows

In PowerShell, with curl.exe, which comes with Windows 10 and later. The second pair of commands stops unless the archive's SHA-256 matches its line in SHA256SUMS. Use secret_windows_arm64 on an Arm PC.

curl.exe -fsSLO https://github.com/deepsecret-io/cli/releases/latest/download/secret_windows_amd64.zip
curl.exe -fsSLO https://github.com/deepsecret-io/cli/releases/latest/download/SHA256SUMS
$want = (Select-String -Path SHA256SUMS -Pattern ' secret_windows_amd64\.zip$').Line.Split(' ')[0]
if ((Get-FileHash secret_windows_amd64.zip -Algorithm SHA256).Hash -ne $want) { throw 'checksum mismatch' }
Expand-Archive secret_windows_amd64.zip -DestinationPath .

Then move secret_windows_amd64\secret.exe to a folder on your PATH. secret.exe is not code signed, so SmartScreen may warn the first time it runs. The CLI keeps its login and keys in %USERPROFILE%\.deepsecret with an ACL that grants your account alone, and refuses a config, login or key file that another account may open.

Verify the signature and the provenance

The checksum catches a damaged download, but SHA256SUMS comes from the same place as the archive. Two more checks prove that the repository's Release workflow made the files. The first needs cosign v3 and SHA256SUMS.sigstore.json from the same release, and checks the Sigstore signature on SHA256SUMS. This form accepts any release; to accept only the one you meant, put --certificate-identity https://github.com/deepsecret-io/cli/.github/workflows/release.yml@refs/tags/ and its tag in place of the pattern.

cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json --certificate-identity-regexp '^https://github\.com/deepsecret-io/cli/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' --certificate-oidc-issuer https://token.actions.githubusercontent.com

The second needs the GitHub CLI, and shows the workflow run, commit and runner that built the archive:

gh attestation verify secret_darwin_arm64.tar.gz --repo deepsecret-io/cli --signer-workflow deepsecret-io/cli/.github/workflows/release.yml

Configure CLI

The CLI ships pointed at https://api.deepsecret.io. You only need to authenticate.

  1. 1

    Sign in with an API key

    secret config --save-api-key --api-key-stdin

    Paste the key and press Enter, so it stays out of your shell history. Or set the DS_API_KEY environment variable.

  2. 1b

    Or authenticate via browser SSO

    secret login

    Opens your browser to sign in. The token is written into the local config.

  3. 2

    Validate your configuration

    secret doctor
  4. 3

    Sign out and clear the stored token

    secret logout

Create Identity

Use the CLI to generate a key pair on your device. By default it registers the public keys with the server and makes the new key pair your primary identity, the one people send to. Private keys are stored at ~/.deepsecret/keys/ and never uploaded.

  1. 1

    Generate a new identity

    secret identity generate

    Registers the public keys and sets the new identity as primary right after generation.

  2. 1b

    Generate with password protection

    secret identity generate --password

    Encrypts your private keys with AES-256-GCM, key derived via Argon2id. You will be prompted each time you encrypt or decrypt.

  3. 1c

    Generate with a security key

    secret identity generate --security-key --recovery-code

    Protects your private keys with a security key such as a YubiKey, in place of a password. It needs a second way in, in case the security key is lost: --recovery-code prints a recovery code, or --backup enrolls a second security key.

  4. 1d

    Keep a key pair on this device only

    secret identity generate --local

    Nothing is registered and your primary identity does not change. Register it later with secret identity sync.

  5. 2

    Register a local identity and set it as primary

    secret identity sync

    For a key pair made with --local, or one whose registration did not finish. Without -i it picks the only key pair not registered yet.

  6. 2b

    Register a specific identity by fingerprint

    secret identity sync -i <fingerprint>
  7. 3

    List your local identities

    secret identity list
  8. 3b

    List identities registered on the server

    secret identity list --remote
  9. 4

    Show public keys for an identity

    secret identity show -i <fingerprint>

    Without -i it shows your primary identity: the key others send to. It shows the key's 30-digit safety number, your half of every safety number people compare with you, and its key fingerprint.

  10. 5

    Set a registered identity as primary

    secret identity set-primary <fingerprint>

Encrypt and Send

Encrypt for one or more recipients by email. The CLI fetches their public keys, encrypts on your device, and uploads the ciphertext once, however many recipients there are. Every recipient can see who else received it. With no recipient, the secret is encrypted to yourself.

  1. 1

    Encrypt key-value data for a recipient

    secret encrypt --to user@example.com -d API_KEY=sk_live_abc123
  2. 1b

    Encrypt for several recipients

    secret encrypt --to alice@example.com --to bob@example.com -f .env

    Repeat --to once per recipient. It is one secret with one upload, and each recipient decrypts it with their own key.

  3. 2

    Encrypt a file

    secret encrypt --to user@example.com -f ./credentials.json
  4. 3

    Encrypt from stdin

    cat .env | secret encrypt --to user@example.com
  5. 4

    Bundle a directory and hide individual file metadata

    secret encrypt --to user@example.com -f ./secrets/ --archive

    Packs the directory into a single tar.zst blob. --hide-metadata is an alias.

  6. 5

    Encrypt to a group

    secret encrypt -f .env -g engineering

    Resolves all members, excluding yourself, and sends them one secret with one upload. See Groups.

  7. 6

    Name a secret

    secret encrypt -d API_KEY=abc --to user@example.com --name "prod credentials"

    Defaults to the filename or a timestamp if omitted.

  8. 6b

    Send a new revision to a changed list

    secret encrypt -f .env --name "prod credentials" --to alice@example.com --revoke-missing

    Sending again under an existing name adds a revision, and the recipients follow the latest send. If it leaves out someone who has access now, the send is refused and names them. --revoke-missing revokes them instead.

  9. 7

    Pipe Kubernetes logs to a teammate

    kubectl logs api-server-7f8d4b6c59-x2ktn | secret encrypt --to bob@startup.io
  10. 8

    Encrypt with a policy

    secret encrypt --to user@example.com -d TOKEN=abc --ttl 24h --ip 10.0.0.0/8

    --ttl and --ip can be set at encryption time. CIDRs accept IPv4 and IPv6 (e.g. 2001:db8::/32).

  11. 9

    Encrypt with a non-primary identity

    secret encrypt --to user@example.com -d TOKEN=abc -i <fingerprint>

    Use -i to pick a specific local identity instead of the primary.

  12. 10

    Skip dotfiles when walking a directory

    secret encrypt -f ./config/ --to user@example.com --skip-hidden
  13. 11

    Tune upload parallelism for large files

    secret encrypt --to user@example.com -f ./big-archive.zst --threads 8

    By default the upload adapts its parallel connections to your link. Set a number to fix it, max 32.

  14. 11a

    Finish an interrupted upload

    secret resume

    Lists large uploads that did not finish. secret resume <id> sends the missing parts and keeps the ones already uploaded; --discard drops it instead. An upload left a day without activity is dropped by the server.

  15. 11b

    Extend the presigned URL window for very long uploads

    secret encrypt --to user@example.com -f ./big-archive.zst --presign-expiry 30m

    Lifetime of the presigned URL for a small upload sent in one request, 1m to 30m, default 15m. Large uploads fetch fresh part URLs as they go, so they need no tuning.

Decrypt and Receive

List and decrypt secrets sent to you. Decryption happens on your device using your private key.

  1. 1

    List secrets sent to you

    secret list
  2. 1b

    List secrets you sent

    secret list --sent
  3. 1c

    Filter by sender or recipient

    secret list --from alice@example.com

    Use --from for inbox filtering, or --sent --to bob@example.com for sent secrets that include that recipient.

  4. 1d

    Paginate results

    secret list --page 2 --limit 100

    Default page size is 20, max 1000. Pass --no-pagination to load everything at once.

  5. 2

    Decrypt a secret

    secret decrypt <secret-id>
  6. 3

    Decrypt files into a directory

    secret decrypt <secret-id> --dir ./downloads/
  7. 4

    Decrypt a specific revision

    secret decrypt <secret-id> --revision <revision-id>
  8. 5

    Decrypt with a non-primary identity

    secret decrypt <secret-id> -i <fingerprint>
  9. 6

    Tune download parallelism for large secrets

    secret decrypt <secret-id> --threads 8

    By default the download adapts its parallel connections to your link. Set a number to fix it, max 64.

  10. 6b

    Extend the presigned URL window for very long downloads

    secret decrypt <secret-id> --presign-expiry 30m

    Lifetime of each presigned download URL. Accepts 1m to 30m. Default 15m. Bump it when a single part might take longer than 15 minutes to fetch.

  11. 7

    Inspect a secret without decrypting

    secret describe <secret-id>

    Add --format json or --format yaml for machine-readable output on list and describe.

Recipient Keys

The CLI checks every key bundle the server hands it: the fingerprint must match the public keys, and both self-signatures (Ed25519 and ML-DSA-65) must verify. That proves a bundle is whole, not that it belongs to the person you named, so the CLI also remembers keys.

The first time you send a secret to someone, or decrypt one from them, this device accepts their key once that send or decrypt succeeds, and says so. After that, any other key is refused: a send stops before anything is encrypted, and a decrypt stops before the download. You decide whether to accept the new key.

Your own accounts are remembered too: any you register a key for, and the one this device first sends from. Only key pairs on this device pass for them: a send to yourself must use one, and a secret from yourself must be signed by one, whatever the server says about who you are. If the server says you are someone this device knows as another person, the CLI refuses and says so.

To check that a key is really theirs, compare your safety number with them: 60 digits in 12 groups of five, made from your primary key and theirs. Both of you see the same number, so you read it to each other over a channel you trust, such as a call or in person. The CLI and the desktop app take your half from the key pair on your device, so a server that hands out keys of its own changes the number. The hex key fingerprint is still shown under it, for scripts and older versions.

  1. 1

    Compare your safety number with someone

    secret identity verify alice@example.com

    Shows your safety number with them, from your primary key and the key the server lists for them, then that key's fingerprint and what this device knows of it. They see the same number with secret identity verify and your email. Without your primary key on this device there is no number: compare the key fingerprint, which they see with secret identity show.

  2. 1b

    Mark it verified once it matches

    secret identity verify alice@example.com <fingerprint>

    Give the full 64-character key fingerprint shown under the number you compared.

  3. 2

    Accept a changed key

    secret identity accept alice@example.com <fingerprint>

    When a send or decrypt is refused because their key changed, the CLI prints your safety number with the new key, its fingerprint and this command. Compare the number with them first. The full fingerprint is required, and it must be a key the server lists for them. It replaces their other keys: sends go to it alone, and a replaced key only checks the secrets it signed. A send to a replaced key is refused until you accept it again.

  4. 3

    List the keys this device accepted

    secret identity known

    Add an email to see one person, with your safety number with them. Each key shows its state (first use, accepted, verified, or yours for your own accounts), and whether it was replaced.

  5. 4

    Forget someone's keys

    secret identity forget alice@example.com

    Add a full fingerprint to forget one key, such as a replaced key that leaked: secrets it signed are then refused too. Once no key is left, the next secret to or from them accepts their key on first use again.

Inbox and Sent

Every secret is filed under one of two views, picked by who the sender and recipients are.

  • Inbox. Secrets other people sent to you, and the secrets you encrypted only to yourself: personal notes, backups, scratch material. In the CLI, secret list lists the secrets sent to you.
  • Sent. Secrets you sent to other people. Use secret list --sent or filter further with --to alice@example.com.

Secrets with a deleted sender identity

When the sender deletes the identity that sealed a revision, the server refuses every download attempt for that revision because the ciphertext can no longer be verified. The row stays visible in the list and the Trust column reads Unverifiable in rose so it can be located and bulk-deleted.

When a recipient deletes their identity the row remains downloadable. That recipient just loses the local private key needed to decrypt; re-syncing a backup of the same identity restores access.

Row columns

Each row scans left-to-right as labeled stat columns.

  • Updated. When the secret row last changed (re-upload, policy amendment, dismissal). Backed by updated_at.
  • Trust. Verified (emerald) when the sender's public identity is on file, so the ciphertext signature can be verified. Unverifiable (rose) when the sender has deleted their identity: the server then blocks downloads. Recipient-side deletion does not flip this state.
  • Policy. Active (blue) when the secret carries a TTL, an IP lock, or both. Expired (rose) once the TTL has elapsed; the server already refuses downloads. A dash when no policy applies.
  • Files. Numeric count of attachments, or TAR when the revision was sent in archive mode (--archive / --hide-metadata); per-file names and sizes are then hidden from the server. A dash when there are no files.
  • Revs. Total revisions ever uploaded for this secret. Older revisions stay downloadable by the recipients they were sealed for.
  • Size. Encrypted bytes for the latest revision (encrypted payload size, not plaintext).

On the Identities and Storage pages a green primary marker is shown in the corresponding row controls. New secrets are always sealed against the primary identity and written to the primary bucket.

Manage Secrets

  1. 1

    Show full metadata for a secret

    secret describe <secret-id> --all

    Shows sender, recipients, policies, all revisions, files, and integrity info.

  2. 2

    Delete a secret

    secret delete <secret-id>

    Aliases: secret rm, secret del. Only the sender can delete.

  3. 3

    Delete several secrets

    secret delete <id-1> <id-2> <id-3>
  4. 4

    Revoke one recipient

    secret revoke <secret-id> --to <email>

    Only the sender can revoke. The person loses access; anything they already downloaded stays with them. Revoking the last recipient deletes the secret.

  5. 5

    Identify yourself

    secret whoami

    Shows account, plan, primary identity, and active organization.

Secret View

Clicking a row in the secrets list opens the detail view. It is metadata-only. Ciphertext is never sent to the browser, and private keys never leave the CLI host. Every field below is served by the same /v1/secrets/<id> response shape used by the SDK and CLI, so what the UI shows and what secret describe prints come from the same source of truth.

The page is composed of three regions, each with a precise meaning. Hover any value in the UI for a tooltip restating the same definitions.

Header

The top row shows sender → recipients resolved from sender_id /recipients: the first two recipients by name, then +N for the rest. A secret you sent only to yourself reads as a single actor on both sides. The names and emails are cached from the actors directory; the underlying identity is the UUID, not the email.

Recipients

Everyone with access now, from recipients. The sender and every recipient see the full list. The sender can revoke each recipient: that person loses access, and anything they already downloaded stays with them. Revoking the last recipient deletes the secret. Revoking stays available while the subscription is inactive, like deleting.

Secret Metadata

Always-visible stat row summarizing the secret as a whole. Field names match SecretResponse in the API.

  • Total Size · total_size. Sum of encrypted bytes across all revisions of this secret. Plaintext size is not stored on the server; if compression was applied the original-size figure is recorded per-revision in the integrity block, not here.
  • Files · files_count. Number of files in the latest revision. Always 0 for archive-mode revisions (--archive / --hide-metadata) because the server never sees individual file entries.
  • Revisions · revision_count. Total revisions ever uploaded for this secret. The latest revision id is latest_revision_id; older ones remain downloadable by the recipients they were sealed for.
  • Updated · updated_at. Last time the secret row changed: re-upload, policy amendment, dismissal, etc. Use created_at for the original send time.
  • Expires · expires_at. UTC instant after which the server refuses downloads. Turns red once the instant has passed. Omitted when the secret has no TTL.
  • IP Lock · computed from allowed_ips. Total number of distinct IPs covered by the allow-list (sum of CIDR sizes). Omitted when the list is empty.
  • Secret ID · id. Stable UUID. Use this with secret describe, secret delete, and any SDK call that takes a secret reference. Click the copy icon for the full value.

Access Policies

Click the metadata row to open the policies panel. You see two columns side by side: what you set as the sender, and what your organization is enforcing. Each policy type gets its own row. IP Lock entries stack inside that row when there's more than one.

The values shown in green are the ones actually in force. The other side stays in normal text so you can still see what was set there. If a side has nothing, it shows N/A.

How does it decide? The organization always wins per policy when it's enforcing one. Otherwise your own value applies. Organization policies act like a live firewall: turning one on, changing it, or turning it off updates every secret in the organization right away. Your own values stay untouched on the secret either way, so if the org later disables enforcement, what you set comes back automatically.

Two policy types are available today. TTL is the moment the secret stops working; once that time passes the server refuses downloads. IP Lock is the list of addresses or ranges allowed to download; the small grey number next to each entry tells you how many addresses that range covers.

Revisions

Below the metadata is a git-style timeline of revisions. The first card is the latest (carries a Latest badge); the rest collapse behind a "show older" toggle. Each card has a compact summary row and an expandable Cryptographic & Integrity Details panel.

Summary fields on the row come from the RevisionResponse shape:

  • Size · size. Encrypted bytes uploaded to storage for this revision. This is the encrypted payload size, not plaintext.
  • Files · files_count. Per-revision count. Hidden for archive-mode revisions.
  • Compressed. Shown when integrity.compressed is true. Value is the size reduction percentage derived from compression_ratio (e.g. ratio 0.4 renders as 60% reduction).
  • Mode = Archived. Shown when integrity.compression == "tar+zstd". The file tree was bundled into a single opaque blob; per-file metadata is not stored.
  • Recipients. How many people this revision was sealed for (integrity.recipients), or Self for a revision sealed only for its sender. A recipient sees the revisions sealed for them, so someone added later sees history from when they were added.
  • Created · created_at. When this revision was confirmed. Independent of the secret's updated_at.

Multipart uploads carry extra fields not surfaced on the row:is_multipart, multipart_state (pending / in_progress /completed / aborted), and multipart_part_size. The SDK uses these to switch between the single-PUT and multipart presign endpoints.

When integrity.sender_key_status == "invalid" the sender's identity has been deleted. The integrity panel renders the Sender Key Fingerprint with a rose fingerprint glyph and the row's Trust column reads Unverifiable. The server blocks all downloads for that revision because the ciphertext can no longer be verified. Recipient-side deletion does not trigger this state: only the sender's fingerprint gates the download.

Cryptographic & Integrity Details

The expandable panel under each revision shows the full integrity block. These are the values an SDK or client uses to verify and decrypt the ciphertext, and to correlate the row with its object in storage.

  • Protocol Version · protocol_version. The DeepSecret protocol version stamped by the CLI at encrypt time. The current value is 4. Decryptors must support this version.
  • Secret ID / Revision ID. UUIDs. The Revision ID is also the object key in the bucket, useful for direct verification with aws s3api head-object.
  • Sender Key Fingerprint · key_fingerprint. Fingerprint of the identity that sealed (signed + encrypted) this revision. Pairs with sender_key_status: primary (current default), secondary (registered but rotated), or invalid (deleted, downloads blocked).
  • Recipient Keys · integrity.recipients. One row per recipient this revision was sealed for: their email, the fingerprint of the key their copy of the content key was sealed to (key_fingerprint), and key_status, the same tri-state as sender. Recipient deletion does not block downloads, but that recipient may no longer be able to decrypt locally.
  • Payload SHA256 · checksum_sha256. SHA-256 of the encrypted payload as uploaded, the payload digest in the sender's signed manifest. Computed client-side and verified by the CLI on download. Mismatch ⇒ tampered transport.
  • Payload Checksum Prefix · etag. The first 16 hex characters of the Payload SHA256, set by the SDK at upload. It is not the storage backend's ETag.
  • Cipher Suite · cipher_suite. Composite identifier of every algorithm in the protocol. The current value is DS4_HPKE_X25519MLKEM768_HKDFSHA256_AES256GCM_STREAM1M_ED25519_MLDSA65.
  • Encryption / KEM / Signing / KDF / Serialization. Literal algorithm slots that make up the cipher suite, fixed by the current protocol: encryption = HPKE, kem = MLKEM768-X25519 (hybrid post-quantum), signing = Ed25519 + ML-DSA-65, kdf = hkdf-sha256, serialization = CBOR.
  • Payload Type · payload_type. One of files, data, or message. Determines how the CLI decodes the plaintext after decryption. Archive mode is signaled separately via compression == "tar+zstd".
  • Original Size · original_size. Plaintext byte count before compression. Present only when compression was applied. Compare with Encrypted Size to see compression efficiency.
  • Encrypted Size · size. Same value shown in the row summary; included in the panel for comparison with Original Size.
  • Compression / Compression Ratio. Algorithm (zstd, tar+zstd, etc.) and the ratio of encrypted-to-original. The UI displays the reduction percentage (1 - ratio).
  • Files. For non-archive revisions, repeats files_count. For archive-mode revisions, shows archived (file count not tracked) because per-file entries are not stored on the server.

Groups

Groups let you encrypt for an entire team in one command. The payload is encrypted and uploaded once, and each member decrypts it with their own key. Members send to the groups they belong to, and owners and admins to any group. Groups are managed at the organization level.

  1. 1

    Encrypt to a group

    secret encrypt -f .env -g engineering

    The CLI resolves all members, excluding yourself, and sends them one secret with one upload.

  2. 2

    List groups in your organization

    secret groups list
  3. 3

    Show members of a group

    secret groups describe <name>

    Equivalent to secret describe group <name>.

Sender Policies

You can attach two kinds of access rules to a secret you sent: a TTL (when it stops working) and an IP lock (which addresses are allowed to download it). Both are optional, both apply to every revision of the secret, and you can change them later as long as your organization isn't enforcing the same rule.

TTL can be anywhere from 2 minutes to 1 year. Leave it off for no expiry; --clear-ttl removes it later.

IP Lock takes up to 50 entries, IPv4 or IPv6, single addresses or CIDR blocks.

New to CIDRs? Read the CIDR primer before pinning a rule.

  1. 1

    Set a TTL on an existing secret

    secret policy <secret-id> --ttl 7d
  2. 2

    Restrict access by IP

    secret policy <secret-id> --ip 10.0.0.0/8 --ip 192.168.1.0/24
  3. 3

    Clear policies

    secret policy <secret-id> --clear-ttl --clear-ips

Transfers

How the CLI and the SDK move large files. None of this changes what is encrypted or who can open it.

  • Streaming. Files are compressed, encrypted and written 1 MiB at a time, so a 100 GB file needs no more memory than a small one. Both sides need free disk space for the encrypted payload; set DEEPSECRET_TMPDIR to choose where it goes.
  • Compression. zstd on several cores, skipped when a sample would not shrink by 5%.
  • Parts. Uploads above the multipart threshold go in parts: 8 MiB, 32 MiB from 1 GiB, and 64 MiB from 16 GiB. Upload links are signed 100 at a time.
  • Connections. A transfer starts with 4 connections and doubles them while throughput rises by more than 10%, up to 32 for uploads and 64 for downloads, and halves them on failures. On a slow link, upload parts go one at a time, so a resume keeps them.
  • Retries and resume. Each part is tried up to 8 times. secret resume sends only what is missing after an interruption.
  • Links. Upload and download links are signed for at most 30 minutes, and never past the secret's expiry. Bytes go straight between your device and the bucket, never through DeepSecret's API servers.

Full CLI Example

Every step from installing the CLI to sending and receiving, in one session you can run on your own: a secret sent with no recipient is sent to you, so you are both its sender and its recipient. It needs macOS or Linux, a Google account, and jq for the step that reads the list as JSON. Step 6 sends to someone else, so put in the address of a person who has an account and keys.

terminal
# 1. Install the CLI in /usr/local/bin. The installer checks the download
#    against the release's checksums, and with cosign installed, that the
#    release workflow signed them.
curl -fsSL https://github.com/deepsecret-io/cli/releases/latest/download/install.sh | bash
secret --version

# 2. Sign in once on this device: your browser opens to sign in with Google.
secret login

# 3. Make your keys here and register their public half. --password seals
#    the private keys; the CLI asks for the password whenever it needs them.
#    The new key becomes your primary key, so skip this step if your
#    account has keys on another device: secret identity list --remote.
secret identity generate --password

# 4. Encrypt a file. With no --to, it is sent to you alone.
printf 'DB_USER=app\nDB_PASS=change-me\n' > app.env
secret encrypt app.env --name app-config --ttl 7d

# 5. It is now the newest secret in your inbox: decrypt it into a folder.
secret list
id=$(secret list --limit 1 --format json | jq -r '.secrets[0].id')
secret decrypt "$id" --dir ./restored
diff app.env ./restored/app.env && echo "decrypted intact"

# 6. Send it to someone else. Compare the safety number this prints with
#    them, on a call, before the first send.
secret identity verify maya@example.com
secret encrypt app.env --to maya@example.com --ttl 24h

# 7. See what you sent to others, then delete your own copy.
secret list --sent
secret delete "$id"

In a script

A server or a CI job has no one to type a password or touch a security key, so it gets an account of its own, an API key in DS_API_KEY, and a key pair made once with secret identity generate and no flag, protected by its file permissions alone. Keep ~/.deepsecret on a persistent volume: generating again makes a new primary key. This script sends a service its settings from a file the pipeline wrote:

send-config.sh
#!/usr/bin/env bash
# send-config.sh sends the payments service its settings, encrypted on this
# machine for the service's own account. Sending again under the same name
# adds a revision to the same secret.
set -euo pipefail

: "${DS_API_KEY:?set DS_API_KEY to the API key of this machine}"

# Only the service's network may download it.
secret encrypt ./production.env \
  --name payments-api/production \
  --to payments-api@example.com \
  --ip 203.0.113.0/24

On the service's machine, under the service's account, this one decrypts the newest of them before the service starts:

fetch-config.sh
#!/usr/bin/env bash
# fetch-config.sh decrypts the newest payments-api/production settings from
# the deploy account, before the service starts. It runs as the service's
# own account, whose primary key is on this machine.
set -euo pipefail

: "${DS_API_KEY:?set DS_API_KEY to the API key of the service}"

id=$(secret list --from deploy@example.com --no-pagination --format json \
  | jq -r 'first(.secrets[] | select(.name == "payments-api/production") | .id) // empty')
if [ -z "$id" ]; then
  echo "no settings from deploy@example.com yet" >&2
  exit 1
fi

# The settings are written in plaintext to ./config, readable only by this
# user, and replace the copy from the last start.
umask 077
secret decrypt "$id" --dir ./config --overwrite

Dashboard

API Keys

API keys authenticate the CLI without going through browser SSO. Create or revoke yours from Account › API keys. Each key is shown exactly once at creation. Treat it like a password: copy it into your CLI config or a secrets manager and never paste it into shared docs.

  • You can hold up to 10 keys. Name each after the machine or script that uses it; the list shows its first characters, when it was last used and when it expires.
  • Choose an expiration when you create a key. An expired key stops working and leaves the list soon after.
  • Revoking a key is instant: the CLI and scripts using it fail on their next request, and your other keys keep working.
  1. 1

    Configure the CLI with the key

    secret config --save-api-key --api-key-stdin

    Paste the key and press Enter. Or set the DS_API_KEY environment variable; either way the key stays out of your shell history.

Notifications

DeepSecret can tell you when someone sends you a secret, when you send one to yourself, when an API key is created on your account, and when one of your identities is added, made primary or removed. Configure delivery from Account.

  • Email. Sent to your account address. Toggle on or off per event.
  • Slack DM. Provide your Slack DM email and DeepSecret routes notifications there. Useful for keeping notifications out of inbox clutter.
  • Webhook. DeepSecret POSTs each notification as signed JSON to a public HTTPS endpoint of yours. It follows no redirects and never sends a secret's contents, which only your keys can open.

Each channel is opt-in per event, so you can keep email for security alerts and route routine activity to Slack or your own systems. Settings are per-actor and travel with you across organizations you belong to.

Verifying a webhook. Deliveries follow the Standard Webhooks scheme, so its libraries verify them. Each request has a webhook-id, a webhook-timestamp and a webhook-signature: v1, and the base64 HMAC-SHA256 of id.timestamp.body, keyed with the base64-decoded part of your signing secret after whsec_. Compare it in constant time, reject timestamps more than five minutes old, and use the id to ignore a retry you already handled. The secret is shown once, when you create the webhook or rotate it.

Create Organization

An organization is the unit DeepSecret uses to group members, group secrets, enforce policies, and bill seats. You can own at most one organization, or a personal workspace for you alone instead. Either one starts with a 7-day free trial, no card needed. You subscribe later as its owner, from Billing.

Open Welcome, choose Organization and provide:

  • A name. Used in invitations and audit log entries.
  • A domain you control (e.g. yourco.com). The domain unlocks two things: auto-join for teammates with matching email addresses, and the option to use DNS-verified ownership for higher-trust features.

Verify DNS

DNS verification proves that the organization controls the domain it claims. The flow is one TXT record and a click.

  1. 1

    Open the DNS page

    Go to Organization › DNS. The page shows your domain, the TXT record name, and the value to publish.

  2. 2

    Add the TXT record at your DNS provider

    Copy the host and value into your provider's zone editor. Propagation is usually under a minute, occasionally up to an hour.

  3. 3

    Click Verify

    DeepSecret performs a TXT lookup against authoritative resolvers. On success, the org is marked verified.

Storage Credentials

DeepSecret stores ciphertext in S3-compatible object storage you provide. The platform never sees the plaintext, and with bring-your-own storage it never holds the ciphertext either. Your provider bills the storage and the downloads (egress) from your own bucket; Cloudflare R2 charges no egress fees. You can connect up to 10 storage credentials per organization. One is marked primary, and that is where new uploads go. Existing secrets are read from wherever they were originally written, so changing the primary is gradual, not destructive.

Manage credentials at Organization › Storage. Owners and admins manage it while the subscription is active.

Storage caps

Each storage can have a cap on the ciphertext it holds, for example 100 GB, or no cap at all. Set it when you add the storage or later from Edit. Before a send is accepted, the API checks that the secret fits in what is left on the primary storage, and refuses it with the space left when it does not, so nothing is uploaded past the cap. Lowering a cap below what is stored is allowed: the storage takes no new secrets until you delete some or raise the cap.

A deleted secret stops counting right away. With hard delete off, its ciphertext stays in your bucket; turn hard delete on to remove it there too.

Every workspace includes DeepSecret Managed Storage: 100 GB for a Personal workspace and 1 TB for an organization, shared by everyone in it. An organization has it from its free trial on and keeps it after subscribing. During a free trial it holds 5 GB, whatever the plan, and subscribing gives the full amount. Its cap is fixed and the same check applies. Deleting a secret there always removes its ciphertext and frees the space; expired secrets are removed within the hour.

Supported providers

  • AWS S3. Native S3.
  • Cloudflare R2. Zero egress fees. Endpoint: https://<account>.r2.cloudflarestorage.com.
  • DigitalOcean Spaces. Endpoint: https://<region>.digitaloceanspaces.com.
  • Backblaze B2. Add it as Custom S3 with the bucket's S3-compatible endpoint, https://s3.<region>.backblazeb2.com.
  • Custom S3. Backblaze B2, Wasabi, MinIO, Ceph, or any other S3-compatible server. Provide the access key, secret, region, bucket, and endpoint URL.

Required bucket permissions

Create credentials scoped as tightly as your provider allows. DeepSecret needs the following on the single bucket you configure:

  • Read and write objects. s3:GetObject and s3:PutObject for the encrypted blobs themselves.
  • Delete objects. s3:DeleteObject for cleanup when secrets are deleted.
  • List the bucket. s3:ListBucket for the verification probe and for orphan cleanup.
  • Multipart upload. s3:AbortMultipartUpload, s3:ListMultipartUploadParts and s3:ListBucketMultipartUploads. Required because every upload of more than a few MiB is split into multipart parts; DeepSecret checks the uploaded parts before completing an upload, aborting in-flight uploads on failure prevents storage bloat, and the list permissions let DeepSecret see and resume incomplete uploads.

The minimum AWS-style policy:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject",
      "s3:AbortMultipartUpload",
      "s3:ListMultipartUploadParts",
      "s3:ListBucket",
      "s3:ListBucketMultipartUploads"
    ],
    "Resource": [
      "arn:aws:s3:::your-bucket",
      "arn:aws:s3:::your-bucket/*"
    ]
  }]
}

On Cloudflare R2 and DigitalOcean Spaces, generate an API token scoped to the one bucket with the equivalent permissions (object read/write/delete and bucket list, including the multipart-upload list). Backblaze B2 and Wasabi expose the same actions under their S3-compatible interfaces. Without the multipart actions, large-file uploads will fail mid-flight and leave orphaned parts in the bucket.

Verification probe

When you save a credential, DeepSecret runs a probe against the bucket: it lists, writes a small canary object, reads it back, and deletes it. Failures point to the specific permission gap so you can fix the policy and retry. Credentials are encrypted at rest before they hit the database, and the secret portion is never returned by the API after creation. To rotate, add a new credential, mark it primary, then delete the old one.

Members and Seats

You manage your team at Organization › Members. The page shows everyone who's already in, anyone you've invited but who hasn't accepted yet, and how many of your paid seats are in use. Each active member takes one seat, and an organization pays for at least 3 seats. Pending invites don't cost a seat until someone accepts.

There are two ways to add people. The simplest is to invite them by email: they get a link, sign up with that address, and land in your org. You can revoke an invite at any time before it's accepted. The other way is domain auto-join: if you turn it on, anyone signing up with an email on your verified domain joins automatically. Handy for larger teams, but verify your domain first so you're sure the right people are coming in.

Roles are owner, admin, and member. The owner is whoever created the organization. Admins can run the place: add and remove people, manage groups, change settings. Members can use the product but can't change the org around them.

Removing a member is instant: their access is cut, the seat opens up, and any CLI session they had on this org stops working. Their personal account survives; they just leave your org.

Running out of seats? Owners can buy more from Billing. Anyone else sees a note telling them to ask the owner.

Organization Policies

As an owner or admin, you can put a ceiling on how long secrets live and which IPs can download them, and have those rules apply to everyone in the organization at once. You set them at Organization › Policies.

Think of it like a firewall. While a rule is on, it covers every existing and new secret in the organization. If you raise or lower the limit, the change reaches every secret on the next time someone reads it. If you turn the rule off, each sender's own value comes back. Nothing is rewritten on the secrets themselves.

Two rules are available:

  • Maximum TTL. Every secret expires at most this many seconds after it was sent. Senders can still set their own TTL, but yours wins as long as the rule is on.
  • Required IP allowlist. Downloads only work from these addresses. Anything the sender stored on the secret is overridden until you turn the rule off. IPv4 and IPv6 are both fine. If you're unsure how to size the ranges, read the CIDR primer before enforcing org-wide.

Secrets a person sends only to themself are never touched by these rules. They're personal backups, so there's nothing for the organization to police.

Sharing and Auto-Join

Two organization-level toggles change the boundaries of how secrets behave. Configure both at Organization › Settings.

External sharing

Decides whether secrets can cross your organization's boundary. By default they can: anyone in the org can send to, and receive from, people outside. Turn it off and the API starts blocking both directions, with the CLI showing a clear message about the policy. Worth using if you have strict data-handling rules and need every secret to stay inside.

Domain auto-join

Decides what happens when someone signs up with an email on your domain. Off by default, which means everyone needs an invite. Turn it on and they join automatically and take a seat the moment they sign up, no invite needed. Convenient for larger teams.

Verify your domain first. Auto-join trusts the domain to decide who's really part of your team, and DNS verification is what proves you actually control it.

Permanent deletion

Controls what happens when a secret is deleted.

  • Soft delete (default). The row is marked deleted in the database. DeepSecret stops handing out the secret's keys and links at once. In your own bucket the ciphertext stays; in DeepSecret Managed Storage it is always removed.
  • Hard delete. When enabled, deleting a secret immediately removes the ciphertext from your bucket as part of the same request. No audit window. Useful when storage cost or compliance demands instant purge. Members see a confirmation prompt that explicitly mentions the operation cannot be reversed.

Audit Logs

The organization's audit log records what happens through DeepSecret's service, each row with the actor, the action, the target, the IP address, the user agent, the request and its status, and the time. Read it at Audit Logs. Owners and admins of an organization can read it, also while the organization is inactive (read-only).

What is recorded. Sends (upload links, confirmed uploads and multipart steps); requests for download links; refusals for an expired secret or an IP lock; secret updates, deletions, dismissals and recipient revocations; changes to groups, API keys, identity keys, storage, notification destinations, the organization and its policies; domain and storage verification; signing out everywhere; and operator actions such as a suspension.

What is not recorded. Successful sign-ins and single sign-outs. Anything after a download: decrypting, opening a saved copy, or reusing a download link already issued, which works for up to 30 minutes from any address. A download is logged when its link is issued, not when it completes, so one transfer can log several rows. The plaintext of a secret never reaches the server, so it is never logged.

Filtering. By actor, category, action, resource, time range and IP, 50 rows a page. There is no export yet.

Retention. 30 days on both plans, after which rows are deleted. A Personal workspace's events are recorded too, but no one can read them.

SDK

Agents and Apps

Give AI agents, services and your own apps a cryptographic identity of their own, and let them exchange secrets end to end, with people and with each other. All the cryptography runs in the program itself, through the open source DeepSecret Go SDK: it generates the keys, encrypts and signs before anything leaves the machine, and verifies and decrypts what arrives. The API stores only ciphertext and routes it, so a plain HTTP call cannot encrypt or decrypt anything. To use another language, port the SDK from PROTOCOL_V4.md.

  1. 1

    One account per agent

    An agent is an account, like a person. Give each agent its own account in your organization, for example a Google Workspace user such as build-agent@yourcompany.com, so it has its own email address, its own keys, its own access and its own lines in the audit log. It takes a seat.

  2. 2

    One API key per agent

    Sign in to the app as the agent's account once, and create an API key under Account › API keys. The agent uses it for everything else. Keep it in your secret manager or the agent's runtime secrets. Creating keys needs that browser sign-in, so a leaked key cannot make more.

  3. 3

    Keys made where the agent runs

    The agent generates its key pair on its own machine, saves it, and registers the public keys with one call. Its private keys never leave that machine, and DeepSecret never has them.

  4. 4

    Agents find each other by email

    A sender asks the server for the recipient's public keys by email, checks their signatures, and remembers them. A key that later changes is refused until someone confirms it, so the server cannot swap one in.

Add the SDK to a Go 1.26 or newer module:

go get github.com/deepsecret-io/deepsecret-go@v0.5.0

Quick Start

The shortest complete program: it sends a message, then reads and decrypts its inbox. It uses the keys and the known keys the CLI keeps in ~/.deepsecret, so run secret identity generate on the machine first, or give the program keys of its own as in Agent Identity. It builds against the SDK v0.5.0.

  1. 1

    Make a module and add the SDK

    go mod init example.com/quickstart
    go get github.com/deepsecret-io/deepsecret-go@v0.5.0
  2. 2

    Save the program as main.go

    main.go
    // Command quickstart sends an end-to-end encrypted message, then reads and
    // decrypts the inbox. It uses the keys "secret identity generate" made on
    // this machine, in ~/.deepsecret/keys.
    package main
    
    import (
        "context"
        "errors"
        "fmt"
        "log"
        "os"
    
        deepsecret "github.com/deepsecret-io/deepsecret-go"
        "github.com/deepsecret-io/deepsecret-go/secrets"
        "github.com/deepsecret-io/deepsecret-go/trust"
    )
    
    func main() {
        if len(os.Args) != 3 {
            log.Fatal("usage: quickstart <recipient-email> <message>")
        }
        ctx := context.Background()
    
        // The API key comes from the environment, never from the code.
        client, err := deepsecret.NewClient("https://api.deepsecret.io", os.Getenv("DS_API_KEY"))
        if err != nil {
            log.Fatal(err)
        }
        // Keys made with --password need it for every send and decrypt.
        password := os.Getenv("KEY_PASSWORD")
    
        if err := send(ctx, client, os.Args[1], os.Args[2], password); err != nil {
            log.Fatal(err)
        }
        if err := readInbox(ctx, client, password); err != nil {
            log.Fatal(err)
        }
    }
    
    // send encrypts and signs the message on this machine, for one recipient,
    // and uploads only ciphertext. The secret expires after a day.
    func send(ctx context.Context, client *deepsecret.Client, to, text, password string) error {
        res, err := client.Secrets.EncryptText(ctx, []string{to}, text, secrets.EncryptOptions{
            Name:     "quickstart",
            TTL:      "24h",
            Password: password,
        })
        var changed *trust.KeyChangedError
        if errors.As(err, &changed) {
            return fmt.Errorf("the key of %s changed to %s: confirm it with them, then run secret identity accept", changed.Email, changed.Got)
        }
        if err != nil {
            return err
        }
        for _, r := range res.FirstUse {
            fmt.Printf("first send to %s: compare key %s with them once\n", r.Email, r.Fingerprint)
        }
        fmt.Printf("sent %s to %s\n", res.Secret.ID, to)
        return nil
    }
    
    // readInbox decrypts the newest secrets sent to this account. Every
    // decrypt checks the sender's signatures before it returns plaintext.
    func readInbox(ctx context.Context, client *deepsecret.Client, password string) error {
        received, total, err := client.Secrets.ListReceived(ctx, secrets.ListOptions{Limit: 10})
        if err != nil {
            return err
        }
        fmt.Printf("inbox: %d secret(s)\n", total)
        for _, s := range received {
            if s.FilesCount > 0 {
                fmt.Printf("%s from %s: files, decrypt them with secret decrypt %s\n", s.Name, s.SenderEmail, s.ID)
                continue
            }
            res, err := client.Secrets.Decrypt(ctx, s.ID, secrets.DecryptOptions{Password: password})
            if err != nil {
                fmt.Printf("%s: %v\n", s.ID, err)
                continue
            }
            switch res.Decrypted.Type {
            case "message":
                fmt.Printf("%s from %s: %s\n", s.Name, res.SenderEmail, res.Decrypted.Message)
            case "data":
                for _, kv := range res.Decrypted.Entries {
                    fmt.Printf("%s from %s: %s is set\n", s.Name, res.SenderEmail, kv.Key)
                }
            }
        }
        return nil
    }
  3. 3

    Give it your API key, and run it

    read -rs DS_API_KEY && export DS_API_KEY
    go run . maya@example.com "the staging database moved"

    Paste the key when the first line waits for it: it is not shown and stays out of your shell history. Set KEY_PASSWORD too if your keys were made with --password. Send to your own address to see the message come back in your inbox.

Agent Identity

Create the client with the agent's API key, and give the agent a folder of its own for its keys and for the keys it learns from others. Several agents on one machine each get their own folder: never share one.

// Each agent keeps its own keys, in a folder only it can read. Run
// several agents on one machine by giving each its own AGENT_HOME.
agentHome := os.Getenv("AGENT_HOME")
if agentHome == "" {
    home, err := os.UserHomeDir()
    if err != nil {
        log.Fatal(err)
    }
    agentHome = filepath.Join(home, ".deepsecret-agent")
}
keysDir := filepath.Join(agentHome, "keys")

client, err := deepsecret.NewClient("https://api.deepsecret.io", os.Getenv("DS_API_KEY"),
    deepsecret.WithKeysDir(keysDir),
    deepsecret.WithKnownKeysFile(filepath.Join(agentHome, "known_keys.json")))
if err != nil {
    log.Fatal(err)
}

// With no password the private keys are stored unencrypted, readable
// only by the user the agent runs as. With one, they are encrypted with
// it (Argon2id and AES-256-GCM), and every send and decrypt needs it.
password := os.Getenv("AGENT_KEY_PASSWORD")

Then generate the agent's key pair, save it, and register its public keys. This runs once, the first time the agent starts:

// createIdentity generates the agent's key pair on this machine, saves it,
// and registers its public keys with the agent's account, so others can send
// to it. The private keys never leave this machine.
func createIdentity(ctx context.Context, client *deepsecret.Client, keysDir, password string) error {
    kp, err := keys.Generate() // Ed25519 and ML-DSA-65 to sign, X25519 and ML-KEM-768 to receive
    if err != nil {
        return err
    }
    fp, err := keys.Fingerprint(kp)
    if err != nil {
        return err
    }
    if err := keystore.Save(keysDir, fp, kp, password); err != nil {
        return err
    }
    if err := client.Identity.Sync(ctx, kp, false); err != nil {
        return err
    }
    // The first key an account registers is its primary key, the one
    // others send to. A later one is made primary here.
    registered, err := client.Identity.ListKeys(ctx)
    if err != nil {
        return err
    }
    for _, k := range registered {
        if k.Fingerprint == fp && !k.IsPrimary {
            if err := client.Identity.SetPrimaryKey(ctx, k.ID); err != nil {
                return err
            }
        }
    }
    me, err := client.Identity.WhoAmI(ctx)
    if err != nil {
        return err
    }
    number, err := keys.SafetyNumber(fp)
    if err != nil {
        return err
    }
    fmt.Printf("%s has the key %s\nsafety number, to compare with whoever sends to it: %s\n", me.Email, fp, number)
    return nil
}

Choose how the private keys are kept:

  • Without a password. Leave the password empty, and the keys are saved unencrypted in a folder only the agent's operating system user can read (0600 files in a 0700 folder, an owner-only ACL on Windows). This fits an unattended agent on a host you trust.
  • With a password. The keys are encrypted with it (Argon2id and AES-256-GCM), and every send and decrypt passes the same Password. Keep the password in your secret manager, not beside the keys, so a copy of the folder alone is useless.
  • With a security key. A YubiKey needs a person to touch it, so it suits people rather than unattended agents. People protect their keys this way with secret identity generate --security-key.

Registering a key emails the account's owner, and that alert cannot be turned off, so a key added by a leaked API key does not go unnoticed. The first key an account registers becomes its primary key, the one others send to; the code above makes a later key primary too. The safety number it prints is the same key as 30 digits: whoever sends to the agent can compare it, once, over a channel they trust.

Encrypt and Send

Every send encrypts on the agent's machine, once, for every recipient, and signs it with the agent's keys. A message:

_, err := client.Secrets.EncryptText(ctx, []string{"ops-agent@example.com"},
    "the deploy window opens at 18:00", secrets.EncryptOptions{})
return err

Key-value pairs, such as credentials:

func sendData(ctx context.Context, client *deepsecret.Client, to string, pairs []string, password string) error {
    data := map[string]string{}
    for _, p := range pairs {
        k, v, ok := strings.Cut(p, "=")
        if !ok {
            return fmt.Errorf("%q is not KEY=VALUE", p)
        }
        data[k] = v
    }
    return report(client.Secrets.EncryptData(ctx, []string{to}, data, encryptOptions(password)))
}

Files and folders, of any size. They stream from disk, so memory stays flat:

_, err := client.Secrets.EncryptPaths(ctx, []string{"ops-agent@example.com"},
    []string{"./report.pdf", "./logs"}, secrets.EncryptOptions{
        Archive: true, // one archive: the server sees no file names or sizes
    })
return err

Rules travel with the secret and the server enforces them: when it expires, and the networks it can be downloaded from. Sending again under the same name adds a revision of that secret:

_, err := client.Secrets.EncryptData(ctx, []string{"ci-runner@example.com"},
    map[string]string{"REGISTRY_TOKEN": os.Getenv("REGISTRY_TOKEN")},
    secrets.EncryptOptions{
        Name:          "ci/registry-token", // sending again under this name adds a revision
        TTL:           "2h",                // gone in two hours
        IPs:           []string{"10.20.0.0/16"},
        RevokeMissing: true, // a new revision drops anyone not listed
    })
return err

To a group: a member lists the groups they belong to, and the agent encrypts for each other member. Owners and admins can list every group with ListGroups.

// sendToGroup sends to every other member of one of the agent's groups.
// The client encrypts for each member: one payload, one key wrap each.
func sendToGroup(ctx context.Context, client *deepsecret.Client, group, text, password string) error {
    me, err := client.Identity.WhoAmI(ctx)
    if err != nil {
        return err
    }
    groups, err := client.Identity.ListMyGroups(ctx, me.OrgID)
    if err != nil {
        return err
    }
    for _, g := range groups {
        if !strings.EqualFold(g.Name, group) {
            continue
        }
        var to []string
        for _, m := range g.Members {
            if !strings.EqualFold(m.Email, me.Email) {
                to = append(to, m.Email)
            }
        }
        return report(client.Secrets.EncryptText(ctx, to, text, encryptOptions(password)))
    }
    return fmt.Errorf("you are not in a group named %q", group)
}

The first send to someone accepts their key and reports it in FirstUse, so you can compare it with them once. After that, a send to a key that changed is refused with a KeyChangedError before anything is encrypted:

// report prints what a send did. A recipient whose key this machine has
// not seen before is accepted on first use: compare it with them once.
func report(res *secrets.EncryptResult, err error) error {
    var changed *trust.KeyChangedError
    if errors.As(err, &changed) {
        return fmt.Errorf("the key of %s changed to %s. Confirm it with them, then accept it with client.Trust.Accept", changed.Email, changed.Got)
    }
    if err != nil {
        return err
    }
    for _, r := range res.FirstUse {
        fmt.Printf("first send to %s, key %s: compare it with them\n", r.Email, r.Fingerprint)
    }
    fmt.Printf("sent %s to %d recipient(s)\n", res.Secret.ID, len(res.Recipients))
    return nil
}

Receive and Decrypt

The agent lists what was sent to it and decrypts each secret: messages and key-value pairs in memory, files into a folder of their own. Every decrypt checks the sender's signatures and the payload's digest before it releases any plaintext.

// readInbox decrypts every secret sent to the agent: messages and key-value
// data in memory, files to a folder of their own.
func readInbox(ctx context.Context, client *deepsecret.Client, dir, password string) error {
    received, _, err := client.Secrets.ListReceived(ctx, secrets.ListOptions{Limit: 50})
    if err != nil {
        return err
    }
    decryptOpts := secrets.DecryptOptions{Password: password}
    for _, s := range received {
        if s.FilesCount > 0 {
            res, written, err := client.Secrets.DecryptToDir(ctx, s.ID, filepath.Join(dir, s.ID), decryptOpts, secrets.WriteOptions{})
            if err != nil {
                fmt.Printf("%s: %v\n", s.ID, err)
                continue
            }
            fmt.Printf("%s from %s: %d file(s), %d bytes, in %s\n", s.Name, res.SenderEmail, len(written.Files), written.Size, filepath.Join(dir, s.ID))
            continue
        }
        res, err := client.Secrets.Decrypt(ctx, s.ID, decryptOpts)
        if err != nil {
            fmt.Printf("%s: %v\n", s.ID, err)
            continue
        }
        switch res.Decrypted.Type {
        case "message":
            fmt.Printf("%s from %s: %s\n", s.Name, res.SenderEmail, res.Decrypted.Message)
        case "data":
            for _, kv := range res.Decrypted.Entries {
                fmt.Printf("%s from %s: %s is set (%d characters)\n", s.Name, res.SenderEmail, kv.Key, len(kv.Value))
            }
        }
    }
    return nil
}

The result says who sent it (SenderEmail), when they signed it (SignedAt), and whether their key was new to this machine (SenderFirstUse). A secret signed by a key that changed is refused with a KeyChangedError. Once you have confirmed the new key with its owner, client.Trust.Accept(ctx, email, fingerprint) accepts it.

Configure a Service

A service can receive its configuration end to end instead of from files or environment variables in plain text. At startup it decrypts the latest secret with a given name from the one account allowed to configure it, and exports each pair before it starts serving. Whoever deploys it sends the settings with secret encrypt -n payments-api/production -d KEY=VALUE -t payments-api@yourcompany.com, or from code.

main.go
// Command config shows a service that receives its configuration through
// DeepSecret: at startup it decrypts the latest secret named
// "payments-api/production" from its trusted sender, and exports each
// key-value pair as an environment variable, before it starts serving.
//
//    export DS_API_KEY=ds_...   # the service account's API key
//    go run .
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    deepsecret "github.com/deepsecret-io/deepsecret-go"
    "github.com/deepsecret-io/deepsecret-go/secrets"
)

const (
    configName    = "payments-api/production"
    trustedSender = "deploy-bot@example.com" // only this account may configure the service
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    // The service's own key pair, created once with the agent example's
    // init, lives in the default ~/.deepsecret/keys of the user it runs as.
    client, err := deepsecret.NewClient("https://api.deepsecret.io", os.Getenv("DS_API_KEY"))
    if err != nil {
        log.Fatal(err)
    }

    // Only what the trusted sender sent. The sender's name comes from the
    // server, but its key signed the secret, and the SDK refuses a key that
    // changed since this machine first saw it.
    received, _, err := client.Secrets.ListReceived(ctx, secrets.ListOptions{Email: trustedSender, Limit: 100})
    if err != nil {
        log.Fatal(err)
    }
    for _, s := range received {
        if s.Name != configName {
            continue
        }
        res, err := client.Secrets.Decrypt(ctx, s.ID, secrets.DecryptOptions{Password: os.Getenv("SERVICE_KEY_PASSWORD")})
        if err != nil {
            log.Fatal(err)
        }
        if res.SenderEmail != trustedSender || res.Decrypted.Type != "data" {
            log.Fatalf("%s: not a configuration from %s", s.ID, trustedSender)
        }
        for _, kv := range res.Decrypted.Entries {
            if err := os.Setenv(kv.Key, kv.Value); err != nil {
                log.Fatal(err)
            }
        }
        fmt.Printf("loaded %d settings from %s, signed %s\n", len(res.Decrypted.Entries), res.SenderEmail, res.SignedAt.Format(time.RFC3339))
        // ... start the service here, reading its settings from the environment.
        return
    }
    log.Fatalf("no %q from %s yet", configName, trustedSender)
}

Checking the sender matters: anyone who may send to the service could send a secret with the same name. The sender's address comes from the server, but its key signed the secret, and the SDK refuses a sender key that changed since this machine first saw it.

Full Agent Example

A complete program an agent can run: it creates the agent's identity, sends messages, key-value pairs, files or a group message, and reads its inbox. It builds against the SDK v0.5.0.

  1. 1

    Make a module and add the SDK

    go mod init example.com/agent
    go get github.com/deepsecret-io/deepsecret-go@v0.5.0
  2. 2

    Save the program as main.go

    main.go
    // Command agent gives an AI agent, or any program, its own DeepSecret
    // identity, and sends and receives end-to-end encrypted secrets with it.
    //
    //    export DS_API_KEY=ds_...                     # the agent account's API key
    //    go run . init                                # create and register its keys
    //    go run . send ops-agent@example.com "deploy window opens at 18:00"
    //    go run . send-data ops-agent@example.com DB_USER=app DB_PASS=s3cr3t
    //    go run . send-files ops-agent@example.com ./report.pdf ./logs
    //    go run . send-group sre "rotate the staging certificates"
    //    go run . inbox                               # decrypt what was sent to it
    package main
    
    import (
        "context"
        "errors"
        "fmt"
        "log"
        "os"
        "os/signal"
        "path/filepath"
        "strings"
    
        deepsecret "github.com/deepsecret-io/deepsecret-go"
        "github.com/deepsecret-io/deepsecret-go/keys"
        "github.com/deepsecret-io/deepsecret-go/keystore"
        "github.com/deepsecret-io/deepsecret-go/secrets"
        "github.com/deepsecret-io/deepsecret-go/trust"
    )
    
    func main() {
        if len(os.Args) < 2 {
            usage()
        }
        ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
        defer stop()
    
        // Each agent keeps its own keys, in a folder only it can read. Run
        // several agents on one machine by giving each its own AGENT_HOME.
        agentHome := os.Getenv("AGENT_HOME")
        if agentHome == "" {
            home, err := os.UserHomeDir()
            if err != nil {
                log.Fatal(err)
            }
            agentHome = filepath.Join(home, ".deepsecret-agent")
        }
        keysDir := filepath.Join(agentHome, "keys")
    
        client, err := deepsecret.NewClient("https://api.deepsecret.io", os.Getenv("DS_API_KEY"),
            deepsecret.WithKeysDir(keysDir),
            deepsecret.WithKnownKeysFile(filepath.Join(agentHome, "known_keys.json")))
        if err != nil {
            log.Fatal(err)
        }
    
        // With no password the private keys are stored unencrypted, readable
        // only by the user the agent runs as. With one, they are encrypted with
        // it (Argon2id and AES-256-GCM), and every send and decrypt needs it.
        password := os.Getenv("AGENT_KEY_PASSWORD")
    
        args := os.Args[2:]
        switch {
        case os.Args[1] == "init":
            err = createIdentity(ctx, client, keysDir, password)
        case os.Args[1] == "send" && len(args) == 2:
            err = report(client.Secrets.EncryptText(ctx, []string{args[0]}, args[1], encryptOptions(password)))
        case os.Args[1] == "send-data" && len(args) >= 2:
            err = sendData(ctx, client, args[0], args[1:], password)
        case os.Args[1] == "send-files" && len(args) >= 2:
            err = report(client.Secrets.EncryptPaths(ctx, []string{args[0]}, args[1:], encryptOptions(password)))
        case os.Args[1] == "send-group" && len(args) == 2:
            err = sendToGroup(ctx, client, args[0], args[1], password)
        case os.Args[1] == "inbox":
            err = readInbox(ctx, client, filepath.Join(agentHome, "received"), password)
        default:
            usage()
        }
        if err != nil {
            log.Fatal(err)
        }
    }
    
    // createIdentity generates the agent's key pair on this machine, saves it,
    // and registers its public keys with the agent's account, so others can send
    // to it. The private keys never leave this machine.
    func createIdentity(ctx context.Context, client *deepsecret.Client, keysDir, password string) error {
        kp, err := keys.Generate() // Ed25519 and ML-DSA-65 to sign, X25519 and ML-KEM-768 to receive
        if err != nil {
            return err
        }
        fp, err := keys.Fingerprint(kp)
        if err != nil {
            return err
        }
        if err := keystore.Save(keysDir, fp, kp, password); err != nil {
            return err
        }
        if err := client.Identity.Sync(ctx, kp, false); err != nil {
            return err
        }
        // The first key an account registers is its primary key, the one
        // others send to. A later one is made primary here.
        registered, err := client.Identity.ListKeys(ctx)
        if err != nil {
            return err
        }
        for _, k := range registered {
            if k.Fingerprint == fp && !k.IsPrimary {
                if err := client.Identity.SetPrimaryKey(ctx, k.ID); err != nil {
                    return err
                }
            }
        }
        me, err := client.Identity.WhoAmI(ctx)
        if err != nil {
            return err
        }
        number, err := keys.SafetyNumber(fp)
        if err != nil {
            return err
        }
        fmt.Printf("%s has the key %s\nsafety number, to compare with whoever sends to it: %s\n", me.Email, fp, number)
        return nil
    }
    
    // encryptOptions are the send settings: expire in a day, and unlock the agent's
    // own key with its password, if it has one.
    func encryptOptions(password string) secrets.EncryptOptions {
        return secrets.EncryptOptions{TTL: "24h", Password: password}
    }
    
    func sendData(ctx context.Context, client *deepsecret.Client, to string, pairs []string, password string) error {
        data := map[string]string{}
        for _, p := range pairs {
            k, v, ok := strings.Cut(p, "=")
            if !ok {
                return fmt.Errorf("%q is not KEY=VALUE", p)
            }
            data[k] = v
        }
        return report(client.Secrets.EncryptData(ctx, []string{to}, data, encryptOptions(password)))
    }
    
    // sendToGroup sends to every other member of one of the agent's groups.
    // The client encrypts for each member: one payload, one key wrap each.
    func sendToGroup(ctx context.Context, client *deepsecret.Client, group, text, password string) error {
        me, err := client.Identity.WhoAmI(ctx)
        if err != nil {
            return err
        }
        groups, err := client.Identity.ListMyGroups(ctx, me.OrgID)
        if err != nil {
            return err
        }
        for _, g := range groups {
            if !strings.EqualFold(g.Name, group) {
                continue
            }
            var to []string
            for _, m := range g.Members {
                if !strings.EqualFold(m.Email, me.Email) {
                    to = append(to, m.Email)
                }
            }
            return report(client.Secrets.EncryptText(ctx, to, text, encryptOptions(password)))
        }
        return fmt.Errorf("you are not in a group named %q", group)
    }
    
    // report prints what a send did. A recipient whose key this machine has
    // not seen before is accepted on first use: compare it with them once.
    func report(res *secrets.EncryptResult, err error) error {
        var changed *trust.KeyChangedError
        if errors.As(err, &changed) {
            return fmt.Errorf("the key of %s changed to %s. Confirm it with them, then accept it with client.Trust.Accept", changed.Email, changed.Got)
        }
        if err != nil {
            return err
        }
        for _, r := range res.FirstUse {
            fmt.Printf("first send to %s, key %s: compare it with them\n", r.Email, r.Fingerprint)
        }
        fmt.Printf("sent %s to %d recipient(s)\n", res.Secret.ID, len(res.Recipients))
        return nil
    }
    
    // readInbox decrypts every secret sent to the agent: messages and key-value
    // data in memory, files to a folder of their own.
    func readInbox(ctx context.Context, client *deepsecret.Client, dir, password string) error {
        received, _, err := client.Secrets.ListReceived(ctx, secrets.ListOptions{Limit: 50})
        if err != nil {
            return err
        }
        decryptOpts := secrets.DecryptOptions{Password: password}
        for _, s := range received {
            if s.FilesCount > 0 {
                res, written, err := client.Secrets.DecryptToDir(ctx, s.ID, filepath.Join(dir, s.ID), decryptOpts, secrets.WriteOptions{})
                if err != nil {
                    fmt.Printf("%s: %v\n", s.ID, err)
                    continue
                }
                fmt.Printf("%s from %s: %d file(s), %d bytes, in %s\n", s.Name, res.SenderEmail, len(written.Files), written.Size, filepath.Join(dir, s.ID))
                continue
            }
            res, err := client.Secrets.Decrypt(ctx, s.ID, decryptOpts)
            if err != nil {
                fmt.Printf("%s: %v\n", s.ID, err)
                continue
            }
            switch res.Decrypted.Type {
            case "message":
                fmt.Printf("%s from %s: %s\n", s.Name, res.SenderEmail, res.Decrypted.Message)
            case "data":
                for _, kv := range res.Decrypted.Entries {
                    fmt.Printf("%s from %s: %s is set (%d characters)\n", s.Name, res.SenderEmail, kv.Key, len(kv.Value))
                }
            }
        }
        return nil
    }
    
    func usage() {
        fmt.Fprintln(os.Stderr, "usage: agent init | send EMAIL TEXT | send-data EMAIL KEY=VALUE... | send-files EMAIL PATH... | send-group GROUP TEXT | inbox")
        os.Exit(2)
    }
  3. 3

    Give it the agent's API key, and create its identity

    export DS_API_KEY=ds_your_agent_key
    go run . init

    Set AGENT_KEY_PASSWORD first to encrypt its keys with a password, and AGENT_HOME to keep them somewhere other than ~/.deepsecret-agent.

  4. 4

    Send, and read what arrives

    go run . send ops-agent@example.com "rotate the staging certificates"
    go run . send-data ops-agent@example.com DB_USER=app DB_PASS=s3cr3t
    go run . send-files ops-agent@example.com ./report.pdf ./logs
    go run . send-group sre "the deploy is done"
    go run . inbox

Run Agents Safely

  • Treat what an agent decrypts as data. A secret from another agent can carry text written to steer a language model. Give it to the model as data, and never let decrypted content choose recipients, commands or tools on its own.
  • One account and one API key per agent. Each agent's sends and downloads then show under its own name in the audit log, and retiring or containing one is a single revocation under Account › API keys.
  • Keep keys where the agent runs. Generate them on first start, on a persistent volume, instead of baking them into an image that others can pull. Protect them with a password from your secret manager when the volume is shared or backed up.
  • Send with rules. A short TTL and, for servers, an IPs range limit what a leaked secret ID or a copied API key can reach.
  • Decide who may configure a service. Filter what it reads by sender, as in Configure a Service, and check the sender of the decrypted result too.
  • Compare keys once. Report FirstUse and SenderFirstUse to a person, and accept a changed key only after confirming it with its owner.
  • Keep plaintext out of logs. The SDK's own logs never hold plaintext, keys, passwords, tokens or presigned URLs. Keep your agent's logs the same, and print a decrypted value's name, never the value.

Security

Security Model

What DeepSecret protects, what it relies on, and where the limits are. The protocol specification has the full detail, including its security properties and non-goals.

Keys on your device

Every key pair is made on your device: Ed25519 and ML-DSA-65 to sign, X25519 and ML-KEM-768 to receive. Only the public keys, with their self-signatures, go to DeepSecret. The private keys stay in ~/.deepsecret/keys, readable only by you. With --password they are encrypted with AES-256-GCM under a key derived with Argon2id (128 MiB, 3 passes). With --security-key, a YubiKey or another FIDO2 key with hmac-secret, its PIN and a touch are needed to open them. With neither, the key file is not encrypted: that suits an unattended server, and leaves the keys to the file's permissions and the disk's encryption (key file design).

Knowing who you send to

The first time you send to someone, or receive from them, your device records their key. After that, a different key is refused before anything is encrypted or downloaded, until you accept it with secret identity accept. That first key comes from DeepSecret's directory, so a dishonest server could hand you its own key on first contact. Compare safety numbers with secret identity verify <email>, on a call or in person, to rule that out. Your half of the number is computed from the key file on your device, not from the server.

What DeepSecret's servers can and can't do

  • They can't read a secret or its key, change a payload or its recipient list without the signatures failing, or swap a key your device already recorded.
  • They can hand out their own key the first time you contact someone, until you compare safety numbers; add members to a group you send to, since group membership comes from the server; ignore expiry, IP lock and revocation, which they enforce; withhold, delete or refuse service; and see the metadata below.

What the servers see

Secret names; the sender and recipients; sizes before and after encryption; when each revision was sent; and the expiry and IP rules. Unless you send files with --archive, also each file's name, size and SHA-256 hash, which is enough to confirm a guessed file. With --archive the files travel as one encrypted archive, but its total size, the secret's name and the recipients still show. Audit logs record IP addresses and user agents, and notifications carry the secret's name and the sender's name to email, Slack or a webhook. Your storage bucket sees object sizes and the address of each download.

Limits

  • No forward secrecy. Someone who steals a recipient's private key can open every secret ever sent to it. Keep keys behind a password or a security key, and rotate them (Key Rotation).
  • Policies are enforced by the server, not by cryptography (Expiry, Deletion and IP Lock).
  • A recipient can copy what they decrypt. Nothing DeepSecret does can take it back.
  • A compromised device sees what its user sees.
  • Signature times come from the sender's clock.

Reviews and reporting

No independent security audit or penetration test has been completed yet. The reviews so far are internal, and automated scans check the SDK's dependencies and code. Report a vulnerability privately through GitHub's security advisories for the SDK; the security policy covers the CLI and the hosted service too. The source of the CLI and the Go SDK is on GitHub, and Install shows how to verify a release.

Read further

Expiry, Deletion and IP Lock

Expiry, IP lock, deletion and revocation control what DeepSecret hands out. They don't reach copies that have already left.

What they control

To decrypt, a recipient's app needs three things from DeepSecret: the signed manifest, the recipient's own key wrap, and a download link. DeepSecret gives them out only while the secret has not expired or been deleted and the recipient has not been revoked, and only to a request from an address on the secret's IP list. Expiry is measured by DeepSecret's clock.

What they don't control

  • A copy already saved. Once a recipient has decrypted a secret, the plaintext is theirs. No expiry, deletion or revocation can erase it.
  • A link already handed out. A download link works until it expires, at most 30 minutes and never past the secret's expiry, and it is not tied to an IP address: the IP rule is checked when the link is requested.
  • A modified app. Revocation is enforced by the server, not by cryptography. Someone who kept the manifest and their key wrap could decrypt the downloaded ciphertext again without DeepSecret.
  • Local files. A secret written with secret encrypt --local has no expiry or IP rule; --ttl and --ip are refused with it.

Deletion and your bucket

In DeepSecret Managed Storage, deleting a secret removes its encrypted objects, and an expired secret is cleaned up the same way. In your own bucket the objects stay unless the organization's owner turns on hard delete in Organization › Storage, which is off by default. Either way, DeepSecret stops handing out their keys and links.

Organization rules

When an organization enforces an expiry or an IP list, it replaces each sender's own, even where it is longer than the sender's (Organization Policies).

Cryptographic Identities

Your cryptographic identity is a set of four key pairs generated on your device. Private keys never leave your machine. DeepSecret uses HPKE (RFC 9180) with a hybrid KEM that combines classical and post-quantum cryptography. An attacker must break both halves to compromise your secrets.

Key Algorithms

Ed25519

EdDSA signature scheme over Curve25519 (RFC 8032; NIST FIPS 186-5). The classical half of the hybrid signature on every revision's manifest.

Signature . RFC 8032

ML-DSA-65

Module-lattice-based digital signature algorithm at NIST security category 3 (FIPS 204). Post-quantum half of the manifest's hybrid signature. Both Ed25519 and ML-DSA-65 must verify before a recipient opens anything.

Signature . Post-quantum . FIPS 204

X25519

Elliptic-curve Diffie-Hellman function over Curve25519 (RFC 7748). HPKE wraps it as DHKEM(X25519, HKDF-SHA-256), which is then combined with ML-KEM-768 to form the hybrid KEM.

ECDH . RFC 7748

ML-KEM-768

Module-lattice-based key encapsulation mechanism at NIST security category 3 (FIPS 203). Combined with X25519 in the MLKEM768-X25519 hybrid KEM so an attacker must break both to recover the HPKE shared secret.

KEM . Post-quantum . FIPS 203

AES-256-GCM

AES (FIPS 197) in Galois/Counter Mode (SP 800-38D) with a 256-bit key and 96-bit nonce, producing a 128-bit authentication tag. It encrypts the payload in 1 MiB chunks under a key derived with HKDF, and is HPKE's AEAD for sealing the content key in each wrap. Any modification of a chunk, its tag, its nonce, or the associated data fails the GCM check at decryption time.

AEAD . FIPS 197 . SP 800-38D

Zstandard

Plaintext is compressed with zstd (RFC 8478) before encryption. Typical ratio is 2.5x to 3.5x for text-heavy payloads (config files, .env, JSON, source) and close to 1x for already-compressed inputs (images, archives). Compression happens before AEAD sealing so the wire size shrinks proportionally.

Compression . RFC 8478

Encryption Details

Protocol identifier

Protocol version 4 uses the cipher suite DS4_HPKE_X25519MLKEM768_HKDFSHA256_AES256GCM_STREAM1M_ED25519_MLDSA65 (DeepSecret's own identifier, not an IANA-registered HPKE cipher-suite codepoint). Both values are fields of the signed manifest, and the version is also bound into every wrap and every payload chunk, so a client detects and refuses any other protocol. Protocol v4 replaces v3 completely.

One payload, many recipients

Each revision is encrypted once, however many recipients it has. A fresh content key encrypts the payload, the content key is sealed separately for each recipient (a wrap), and the sender signs a manifest that lists the payload digest and every recipient. Storage holds one payload object per revision. Below, fields(...) means SHA-256 input where each value is written with a 4-byte big-endian length prefix.

Standards lineage

The framework (key schedule, KDF, AEAD, modes) is HPKE per RFC 9180 as implemented by Go's standard crypto/hpke package. The KEM is MLKEM768-X25519, also known as X-Wing, defined in draft-ietf-hpke-pq with KEM ID 0x647a. RFC 9180's IANA registry only covers DHKEM variants over P-curves and X25519/X448; the post-quantum hybrid is layered on top through HPKE's standard KEM-extension mechanism. Calling the construction “HPKE” is accurate for the framework; the KEM half is sourced from a separate (and still-evolving) IETF draft.

Content key and payload key

The content key K is 32 random bytes from the operating system's CSPRNG, generated for one revision and never reused. The payload key is PK = HKDF-SHA256(ikm = K, salt = empty, info = "deepsecret/v4/payload", L = 32). Only PK touches the payload, which keeps the wrapped secret separate from the AEAD key.

Payload stream (AES-256-GCM, 1 MiB chunks)

The plaintext, compressed and archived first, is split into 1 MiB chunks. The last chunk may be shorter, and an empty payload is a single empty last chunk. Chunk i, counting from 0, is sealed with PK using this nonce and associated data:

  • Nonce: i as an 11-byte big-endian counter, then one byte: 0x01 for the last chunk, 0x00 otherwise.
  • Associated data: SHA-256(fields("deepsecret/v4/chunk", version, revision_id, payload_type, uint32_be(chunk_size))).

The stored object is the chunk ciphertexts in order; every chunk but the last is exactly 1 MiB plus its 16-byte tag. The counter stops chunks being reordered or dropped, and the last-chunk flag stops truncation and appended data. A counter nonce is safe because PK encrypts exactly one stream. The payload digest is SHA-256 of the stored object, computed while encrypting.

Per-recipient wraps

After the payload is encrypted, K is sealed once for each recipient with HPKE, using a fresh ephemeral key for every wrap, empty associated data, and the info

SHA-256(fields("deepsecret/v4/wrap", version, revision_id, sender_fp, recipient_fp, payload_digest))

A wrap is the encapsulated key enc (1120 bytes) and the sealed content key ct (48 bytes). It opens only to the key it was made for, and the info binds it to this sender, this recipient, this revision and these payload bytes, so it cannot be moved to another revision or payload. Its digest is SHA-256(fields(recipient_fp, enc, ct)). A secret sent only to yourself has a single wrap, to your own key.

HPKE construction (RFC 9180 §5)

Every wrap uses the same parameters. Mode: Base (no PSK, no sender authentication at the HPKE layer; sender identity is proved by the dual signatures below). KEM ID: 0x647a (MLKEM768-X25519 / X-Wing). KDF ID: 0x0001 (HKDF-SHA-256, RFC 9180 §7.2). AEAD ID: 0x0002 (AES-256-GCM, RFC 9180 §7.3). The HPKE key schedule (§5.1) takes the KEM shared secret and the info input and derives the AEAD key, the base nonce, and an exporter secret through LabeledExtract / LabeledExpand.

Hybrid KEM (X-Wing, draft-ietf-hpke-pq)

The KEM encapsulates twice in parallel and combines the two shared secrets: ML-KEM-768 produces (ssPQ, ctPQ) from the recipient's ML-KEM-768 public key; X25519 produces (ssT, ekT) from a fresh ephemeral keypair against the recipient's X25519 public key. The encapsulated key stored in the wrap is enc = ctPQ ‖ ekT. The HPKE shared secret is SHA3-256(ssPQ ‖ ssT ‖ ctT ‖ ekT ‖ label), where label is the X-Wing domain-separator. An attacker has to break both halves to recover the shared secret; reading either alone reveals nothing about the content key.

Manifest

The manifest is a canonical CBOR map (RFC 8949), deterministic so every implementation hashes the same bytes. It holds the version and cipher suite, the revision ID, the secret's name, the sender's fingerprint and full public-key bundle (Ed25519, X25519, ML-KEM-768, ML-DSA-65), the payload type and compression settings, the chunk size, the plaintext and ciphertext sizes, the payload digest, a timestamp, and every recipient as a pair of fingerprint and wrap digest, sorted by fingerprint. It lists 1 to 1000 recipients with no duplicates, and readers refuse a manifest over 1 MiB before parsing it. Every recipient sees the full recipient list.

Dual signature (sender authenticity)

SignableBytes is SHA-256 over every manifest field except the two signatures, in order, each written with its length prefix: integers as 8 bytes big-endian, and the recipient list as a 4-byte count followed by each fingerprint and wrap digest. The sender signs it with Ed25519 (RFC 8032 / FIPS 186-5) and with ML-DSA-65 (FIPS 204), and both must verify. Every recipient holds the content key and could encrypt other content under it, so the AEAD alone cannot show who wrote the payload; the signature over the payload digest does. Covering the recipient list stops anyone adding, removing or swapping a recipient unnoticed.

Opening a revision

A recipient performs these steps in order and stops at the first failure:

  1. Fetch the manifest and their own wrap. The API returns only the caller's wrap.
  2. Check the manifest's structure (limits, sorting, sizes), recompute the sender fingerprint from the embedded keys, and verify both signatures.
  3. Check that the revision ID is the one requested, that the signed secret name is the secret's, that their own fingerprint is listed, and that their wrap's digest matches the listed one.
  4. Open the wrap with HPKE to get K, and derive PK.
  5. Download the payload, then check its size and its SHA-256 against the payload digest before releasing any plaintext.
  6. Decrypt the chunks in order, requiring the last-chunk flag on the final chunk only and accepting no bytes after it.
  7. Decompress and extract.

When it opens a secret's latest revision, the client also refuses one the sender signed earlier than a revision of that secret it has already opened, so the server cannot roll a secret back to an older value.

Compression order

Compression is applied to the plaintext before encryption: zstd (RFC 8478) reduces the input, and the compressed bytes become the AEAD plaintext. Ciphertext is uniformly random and not compressible, so the order matters; reversing it would defeat the compression entirely. The compression flag and algorithm name are fields of the signed manifest, so both endpoints agree on whether to inflate after decryption and any tampering fails verification.

Integrity surface (server-side)

The server stores the payload digest in checksum_sha256 and its first 16 hex characters in etag. The SDK computes both before upload; etag is not the storage backend's ETag header. When a send is created, the server parses the manifest without verifying its signatures and checks that it is version 4, that its revision ID, ciphertext size, payload digest and sender fingerprint match the request, and that it lists exactly the request's recipients with matching wrap digests. At upload confirmation it checks the stored object's size. Recipients still verify the signatures and the payload digest themselves. Revocation is enforced by the server, not by cryptography.

On-disk key protection

Private keys stored at ~/.deepsecret/keys/ can optionally (and highly recommended) be encrypted with a password using AES-256-GCM. The key is derived from the password via Argon2id (RFC 9106). Without --password, keys are written in plaintext under file-system permissions only.

Key Rotation

When you generate a new identity, it is registered and becomes your primary identity unless you pass --local. Your previous secrets stay decryptable as long as the old key remains registered. Recipients verify the sender's dual signatures (Ed25519 and ML-DSA-65) using the public keys embedded in each revision's signed manifest.

People who know your old key. Anyone who sent you a secret or decrypted one from you has your old key on their device. Their next send to you, or decrypt of a secret you signed with the new key, is refused until they accept it with secret identity accept. Compare your new safety number with them over a channel they trust: each of you runs secret identity verify with the other's email. See Recipient Keys.

Server-side gate. The API refuses to serve ciphertext to any actor with no registered public identity. Delete every key and downloads stop. Re-register a local identity with secret identity sync -i <fingerprint> to restore access.

Already downloaded payloads. If you pulled the manifest, your wrap and the payload before the key was deleted, the CLI can still open the wrap with the private key you still hold. The signatures still check against the keys embedded in the manifest, but those keys no longer belong to a registered identity, so the plaintext comes out without any guarantee of who produced it.

In short: deleting a key revokes both download access for new fetches and sender authenticity for any manifest signed under that identity. Confidentiality of already-decrypted plaintext is unaffected.

Understanding CIDRs

An IP allow-list works on ranges, not single addresses. The shorthand for a range is a CIDR: one IP followed by a slash and a number, like 192.168.1.0/24. The number after the slash is what really matters: it tells you how big the range is.

The smaller the number, the bigger the range. The bigger the range, the more loosely you're trusting the network. Think of it as how much of the address you're pinning down: /32 pins down everything (one machine),/24 pins down the first three octets (one office), /8 pins down only the first (everything that begins with a single number, often an entire ISP). Same idea for IPv6, just with a longer address: /128 is one machine,/64 is a typical home network, and lower numbers are progressively wider slices of the internet.

Quick reference

  • 1.2.3.4 on its own. The CLI and the dashboard accept a bare address. Internally it becomes 1.2.3.4/32, that exact machine and no one else.
  • 1.2.3.4/32. Same as above, just written out. One IPv4 host.
  • 192.168.1.0/24. Every address from 192.168.1.0 through 192.168.1.255. Roughly “one office” or “one VPN subnet.”
  • 10.0.0.0/16. About 65,000 addresses. A whole campus or a large datacenter VPC.
  • 10.0.0.0/8. About 16 million addresses. The entire 10.x.x.x private space.
  • 0.0.0.0/0. Every IPv4 address on the planet. Effectively no restriction.
  • ::1/128. One IPv6 host (in this case the loopback).
  • 2001:db8::/32. A large IPv6 block. Don't use it unless your operator has actually told you to.

How to choose

The narrower the range, the stronger the rule. Pick the tightest CIDR that still covers everyone who legitimately needs the secret. If you only have one server pulling, use that server's exact address. If a whole VPN egress should be allowed, use the VPN range. Don't use a wider range “just to be safe”: that's the opposite of safe.

Some practical rules of thumb:

  • Pinning a single API server, build runner, or developer workstation? /32 for IPv4 or /128 for IPv6. One host, no slack.
  • Allowing a small office or VPN exit pool? Use the actual CIDR your network team gives you, usually a /24 or smaller.
  • Anything wider than /16 for IPv4 or /48 for IPv6 is rarely the right choice. It usually means “I don't actually know which addresses,” and that's a sign the rule needs rethinking before it goes in.
  • On dual-stack networks, list the IPv4 and IPv6 ranges separately. A client connecting over IPv6 will not match an IPv4-only entry, even if the user is sitting in exactly the network you intended to allow.