cdrop — technical manual
Version 0.22.0
2026.11.05
This manual covers the purpose, architecture, API, configuration, access tokens, integration and deployment of cdrop.
1. Purpose
cdrop is a small HTTP service for authenticated
publication of short-lived text artifacts with delivery over a
short link and/or QR code and a limited number of fetches.
The service solves one problem:
Publish text with a secret token, get a short URL, serve that text at most N times within a TTL, then forget it.
cdrop is not:
- a certificate authority (CA);
- a key or secret store;
- a messenger;
- a long-term archive;
- a file-sharing service for arbitrary files;
- a service for transferring passwords or private keys.
It is transport, not a source of trust. The recipient must verify the signature, chain or origin of the artifact independently.
1.1. What can be published
Three artifact classes are accepted:
- cnet artifacts — armored
CNET INVITATION,CNET REQUEST,CNET PASSand other blocks with theCNETprefix; - public PEM — X.509 certificates (including chains), CSRs, CRLs, public keys, PKCS#7;
- SSH — public keys and SSH certificates in
authorized_keysform.
Only the format, the number of blocks and the total size are checked. Signatures, networks and certificate chains are not verified: a publish token guarantees only who published, and the web form relies on captcha and quotas. The recipient must verify the material independently.
2. Why cdrop exists
cnet artifacts are ordinary text, 1.3–4.5 KB. Passing them around is inconvenient:
- messengers and email break line endings and armor;
- an 80×25 terminal QR holds only very little text;
- a full Passport (4.4 KB) does not fit into a single QR even at error-correction level L;
- copying through the clipboard to a phone adds steps and mistakes;
- long-lived links and public pastebins provide no control over TTL or the number of fetches.
cdrop provides:
- a short URL (~35 characters) that fits into QR v2–v4;
- TTL from one hour to one week;
- at most three fetches;
- one publication format without signature or chain verification;
- no accounts and no user interface;
- a minimal attack surface.
3. Demand
3.1. Who uses it and why
- PKI teams: hand a CSR, certificate, CRL, chain or public key to a colleague or a phone.
- DevOps/SRE: publish a signed manifest, a test certificate, a WireGuard/SSH/JWKS public key for a few hours.
- cnet: Invitation → Request → Passport without messengers or manual copying.
3.2. Similar services and the difference
| Class | Examples | cdrop difference |
|---|---|---|
| Pastebin | PrivateBin, Hastebin | no TTL policy or fetch limit in the short link |
| One-time secrets | onetimesecret, Yopass, Bitwarden Send | cdrop is not E2E and not for secrets |
| File sharing | transfer.sh, file.io | cdrop accepts only text/PEM and requires a token |
| Git storage | Gist, S3 presigned | long-lived, inconvenient for QR and phones |
| PKI tools | step-ca, CFSSL | issue certificates but do not deliver the artifact to a person |
cdrop occupies a narrow niche: public artifacts,
short lifetime, short link, mandatory publish
authorization.
3.3. Honest assessment
This is not a mass-market SaaS and is unlikely to become one. Its value is an infrastructure tool and a by-product of cnet. The main demand is inside infrastructure and PKI teams. For secrets and private keys, use dedicated E2E tools.
4. Architecture
4.1. Layers
cmd/cdrop CLI and server
internal/config YAML/JSON, environment variables
publish Validator contract, Item{Kind, Scope}, Quota
store SQLite/PostgreSQL, atomic fetch
server HTTP, limits, quotas, metrics, pages
artifact cnet/PEM/SSH format validator
web embedded HTML pages
4.2. Independence from cnet
The core (publish, store,
server, web, artifact) does not
import cnet/auth or cnet/uid. The invariant is
checked by:
make -C cdrop check-coreThere is no signature, trust or chain verification in the core. This allows the core to be reused in other projects and cdrop to evolve as a separate product.
4.3. What the service accepts
The service accepts artifacts of three classes: cnet, public PEM and
SSH. One instance serves all classes; no separate policy setting is
required. Classes are not mixed in one publication, but a chain of
several PEM blocks is one publication of class pem. The
service checks the format, the number of blocks and the total size; it
does not verify signatures or certificate chains.
5. How the service works
5.1. Publish
- The client calls
POST /api/v1/objectswithAuthorization: Bearer. - The token, size and TTL are checked.
- The validator checks the body format:
- cnet — armor correctness;
- pem — allowed block types (chains are permitted), private keys forbidden;
- ssh — key/certificate type, base64 and type match.
- Token quotas are checked.
- An ID is generated (12 characters of Crockford Base32, 60 bits).
- The body is stored with a TTL and a fetch counter.
id,url,expires_at,remainingandsha256are returned.
5.2. Fetch
GET /o/<ID>— HTML page;GET /o/<ID>.txt— the same text astext/plain;- every successful fetch decrements the counter;
HEADand metadata do not decrement it;- after zero or the TTL —
410 Gone; - unknown ID —
404; - fetch is atomic:
UPDATE ... RETURNINGnever returns more bodies than remain.
5.3. Delete
DELETE /api/v1/objects/<ID> requires a token.
Deletion is soft: the counter is set to zero and the object returns
410 until the cleanup cycle removes the row.
5.4. Cleanup
A background loop runs every 10 minutes, removes expired and soft-deleted rows and then trims the database to its size limit. Artifact bodies are not kept longer than the TTL plus one cleanup cycle.
5.5. Database size limit (drop head)
When the database may be untrusted or publishing is unbounded, the size is limited by configuration:
max-db-bytes: 1073741824 # 1 GiB; -1 - unlimited
max-objects: 0 # 0 - unlimitedBefore each insert, in the same transaction, the service reads
COUNT(*) and SUM(size). If the new row does
not fit, the oldest publications (created_at, then
id) are deleted in batches of 100 until there is room. This
protects the database from being filled by unbounded publishing or a
compromised account. If the artifact alone exceeds the limit, the
service returns 507 Insufficient Storage. In PostgreSQL
eviction is serialized with an advisory lock. size exists
since migration 6 and is the plaintext artifact size (at-rest encryption
adds a small overhead).
5.6. Pages
/— node home page (free labin the test environment);/ru/— the same in Russian;/cdrop— publish form (EN) or API reference;/cdrop/api— full API reference (EN);/ru/cdrop— the same in Russian;/ru/cdrop/api— full API reference (RU);/cdrop/manualand/ru/cdrop/manual— full manual (HTML);/o/<ID>— artifact page with copy and download.
6. HTTP API
Base: https://<host>.
6.1. Publish
POST /api/v1/objects
Authorization: Bearer <publish-token|grant-token>
Content-Type: text/plain; charset=utf-8
X-CDROP-TTL: 24h
X-CDROP-GRANT: request=3
X-CDROP-GRANT is optional and works only with a publish
token: it asks to issue a limited grant together with the publication.
kind must belong to the artifact scope
(request for cnet, pem for PEM,
ssh-key/ssh-cert for SSH); uses
is 1..grants.max-uses.
The body is cnet armor, public PEM blocks (a chain is allowed) or SSH
lines. When a grant is requested, the service embeds the full link
https://<host>/p/<token> into the artifact
(armor/PEM header X-CDROP-GRANT, SSH comment
# X-CDROP-GRANT: ...).
Response 201 Created:
{
"id": "7Q2M9K4TABCD",
"url": "https://drop.example/o/7Q2M9K4TABCD",
"expires_at": "2026-11-05T12:00:00Z",
"remaining": 3,
"sha256": "9f2c...",
"grant": {
"kind": "request",
"url": "https://drop.example/p/cdg_...",
"remaining": 3,
"expires_at": "2026-11-05T12:00:00Z"
}
}grant is present only when a grant was requested or the
request was made with a grant token (then url is empty and
remaining is the remaining number of grant
publications).
Error codes:
| Code | Reason |
|---|---|
400 |
not an artifact, empty body, invalid X-CDROP-TTL or
grant |
401 |
token missing/invalid/expired/disabled |
403 |
scope not allowed, wrong kind, nested grant |
410 |
grant expired or exhausted |
413 |
body exceeds the limit |
422 |
private key, unsupported block, mixed classes, more than 16 blocks |
429 |
rate limit or quota (quota returns
Retry-After: 3600) |
507 |
database size limit, no room could be freed |
Responses 400/422/403 are deliberately generic; details
go only to the log.
6.1.1. Limited grant
A grant is a capability link: whoever receives it may publish the specified artifact type N times without a publish token. The grant:
- lives in the parent publication row and disappears with it (DELETE, takedown, expiry, drop-head eviction);
- does not allow metadata, deletion, the admin panel or the captcha form;
- cannot issue another grant:
X-CDROP-GRANTwith a grant token is403; - is stored only as SHA-256; the log records a grant id;
- consumes a use and inserts the child publication in one transaction.
6.2. Fetch
GET /o/<ID>
GET /o/<ID>.txt
| Code | Meaning |
|---|---|
200 |
artifact body |
404 |
unknown ID |
410 |
expired, exhausted or deleted |
Page headers: Cache-Control: no-store,
X-Robots-Tag: noindex,
Referrer-Policy: no-referrer,
Content-Security-Policy (script nonce).
6.3. Metadata
GET /api/v1/objects/<ID>
Authorization: Bearer <publish-token>
{
"id": "7Q2M9K4TABCD",
"kind": "invite",
"scope": "cnet",
"size": 1302,
"sha256": "9f2c...",
"created_at": "2026-11-04T12:00:00Z",
"expires_at": "2026-11-05T12:00:00Z",
"remaining": 2
}Metadata contains the network and fingerprint, so it requires a
token. Without a token — 401; foreign scope —
403.
6.4. Delete
DELETE /api/v1/objects/<ID>
Authorization: Bearer <publish-token>
204 — deleted; 401 — token;
403 — scope; 404 — no such ID;
410 — already expired/exhausted/deleted.
6.5. Publish web form
GET /cdrop
GET /cdrop/api
GET /publish
POST /publish
GET /ru/publish
POST /ru/publish
GET /p/<token>
POST /p/<token>
GET /ru/p/<token>
POST /ru/p/<token>
/p/<token> is the grant publication form: no
captcha and no token field, it accepts only the grant artifact type and
shows the remaining publications. On success it returns a link, QR and
the remainder; when exhausted — 410 Gone.
/cdrop is the entry point: when the form is enabled
(web-publish: true) it serves the publish form, otherwise
the API reference. The reference is always available at
/cdrop/api; the form links to it as «API reference».
Russian variants are /ru/cdrop and
/ru/cdrop/api.
GET /publish serves an HTML form: artifact, TTL
(1h/24h/168h) and captcha. When grants are enabled the form also has an
“issue a grant” checkbox; the number of publications is taken from
grants.uses (3 by default), and the type is chosen by
scope: request for cnet, pem for PEM, ssh-key/ssh-cert for SSH.
POST verifies the captcha (own arithmetic, in-memory,
10-minute TTL, bound to IP, single-use) and publishes the artifact
without a publish token. A limit of 10/min and an IP quota apply (20
publishes, 1 MiB, 50 active by default).
The form text is the same for all artifacts: it lists cnet, public
PEM (including chains) and SSH; the placeholder is
-----BEGIN CNET INVITATION-----.
The result is HTML with a link, a Copy button and a QR code (PNG data URI). If a grant was requested, the result also shows the full grant link, a Copy button and a QR code; the link is embedded into the artifact as well. The form accepts all supported artifacts; signatures and chains are not verified, so quotas, abuse handling and takedown are especially important. The captcha is only a speed bump, not protection against targeted bots.
6.6. Administrator panel
GET /admin
GET /ru/admin
POST /admin/delete
POST /admin/delete-all
Login uses the /admin/login form (or
/ru/admin/login): enter a valid publish token (not revoked,
not disabled, not expired). The session is kept in an
HttpOnly/Secure/SameSite=Strict cookie for 30 minutes (the “Remember me
for 30 days” checkbox — 30 days); Basic and Bearer are still accepted
for curl. The panel is available over TLS only. The cookie holds a
random session ID, not the publish token; sessions are stored in
state-dir/admin_sessions.json (0600) and survive a service
restart. Browsers are redirected to the form; API clients get
401 and WWW-Authenticate.
The table shows every objects row, including expired and
exhausted ones, without bodies: ID, kind, scope, size, creation, expiry,
remaining, fetched, token-id, publisher IP, source
(api/web), User-Agent and status. Sorting is
done by column headers, including IP and source; pages of 25/50/100.
The IP is the TCP peer address; behind a reverse proxy it is the proxy address. IP and User-Agent are stored only in the publication row and are deleted with the artifact; they are not written to the log. The User-Agent is truncated to 200 characters and control characters are replaced with spaces.
Clicking an ID opens the artifact card: body, IP, source, client and other metadata. Viewing in the panel (or with an admin session/publish token) does not decrement the fetch counter; a regular recipient following the short link does.
Deletion is hard: selected rows or all rows disappear from the
database and free space. Forms use CSRF double-submit cookies; delete
all requires confirmation. The «Sign out» button in the header ends the
session (POST /admin/logout).
6.7. Service endpoints
GET /healthz—200 okif the database is available, else503;GET /readyz—200 readyif the database and schema are readable, else503;GET /.well-known/security.txt— abuse contact fromabuse-contact;GET /metrics— loopback only or withCDROP_METRICS_TOKEN, otherwise404.
6.8. Binary file store
Since 0.24.0 cdrop can publish binary distributions (APK, ZIP, TGZ
and other files) as well as text artifacts. The feature lives in a
separate binstore module and is enabled by the service
mode.
6.8.1. Modes
The mode field selects the service:
pki— PKI/CNET artifacts only (the default);store— the binary file store only;pki+store— both services at once.
In pki mode the /admin/store and
/s/<token> routes do not exist and no
bin_* tables are created. In store mode
PKI/CNET publishing is disabled and /admin redirects to
/admin/store.
6.8.2. Sign-in and upload
The store is available only to the administrator, using the same
token as the /admin panel (login form, Basic or Bearer).
There is no anonymous upload.
- Sign in at
/adminand click “Store” (or open/admin/store). - Click “Upload file”.
- Enter a description and a TTL in days, then choose a file.
0means keep forever,365means one year. - During the upload the form shows percent, transferred bytes and
speed; the Cancel button aborts it. The file is streamed straight to the
store, without an intermediate copy in
/tmp. - The object page then shows the name, description, size, TTL, upload and expiry dates, SHA-256 and status.
The object table supports sorting by upload order, name, size, TTL and description, with 25/50/100 pagination. Deleting an object removes its file and all its links.
6.8.3. Short links and QR
On the object page enter a note (who the link is for) and the maximum
number of downloads. 0 means unlimited — intended for very
close relatives or trusted colleagues.
The service returns a short link
https://<base-url>/s/<token> and a page with a
QR code. The link table shows the note, object, maximum, actual
downloads, remaining count, creation date and status. A link stops
working after the maximum is reached (410); a HEAD request does not
consume a download. Individual links can be deleted.
6.8.4. TTL, capacity and cleanup
store.default-ttl-days— default TTL (0 — forever);store.max-ttl-days— maximum TTL (3650 by default);store.max-size— per-file limit (1 GiB by default, 8 GiB ceiling);store.max-total-bytes— total size limit (0 — unlimited);store.max-objects— object count limit (0 — unlimited).
Expired objects are removed by the cleanup loop every 10 minutes,
together with their files and links. Unlike PKI publications, old files
are not evicted automatically: when capacity is exhausted an upload gets
507. On a small host set store.max-total-bytes well below
the free disk space.
Files live in
<state-dir>/store/objects/<xx>/<id> with
mode 0600; metadata lives in the bin_objects and
bin_links tables of the same database. A PostgreSQL backup
(pg_dump) preserves metadata but not file bodies: include the store
directory in a file-level backup.
6.8.5. Examples (Bearer token)
# upload
curl -fsS -H "Authorization: Bearer $CDROP_TOKEN" \
-F file=@release.apk \
-F description="Release 1.0" \
-F ttl=365 \
https://drop.example/admin/store/upload
# create a link to an object
curl -fsS -H "Authorization: Bearer $CDROP_TOKEN" \
-d object_id=<ID> -d note="for Ivan" -d max=1 \
https://drop.example/admin/store/link
# download via the short link
curl -fLO https://drop.example/s/<token>The description and ttl fields may come
before or after the file. The csrf field for cookie/Basic
auth must come before the file; a Bearer token needs no CSRF. With
X-Requested-With: XMLHttpRequest the service returns
201 {id,url} instead of a redirect — this is what the
form’s progress bar uses.
7. Configuration
Format — YAML or JSON. Path: -config or
CDROP_CONFIG. Fields use kebab-case (base-url,
max-size, publish-tokens,
web-quota, token-env), as in cnet. Old
snake_case keys (base_url, max_size, …) are
still read; if both are present, kebab-case wins.
Example:
listen: ":80" # HTTP: redirect and ACME
listen-tls: ":443" # HTTPS; empty - TLS off
tls-cert: "/etc/cdrop/tls/fullchain.pem" # full chain
tls-key: "/etc/cdrop/tls/privkey.pem" # private key
challenge-dir: "/var/www/certbot" # ACME webroot; empty - off
base-url: "https://drop.example" # public URL without trailing "/"base-url: "https://drop.example" # public URL without trailing "/"
# canonical-url: "https://drop.example" # security.txt; empty - base-url
state-dir: "/var/lib/cdrop" # state, mode 0700
db: "postgres" # sqlite | postgres
dsn: "host=/var/run/postgresql dbname=cdrop user=cdrop sslmode=disable"
max-size: 16384 # bytes; max 65536
max-db-bytes: 1073741824 # 1 GiB; -1 - unlimited
max-objects: 0 # 0 - unlimited rows
default-ttl: "24h" # default lifetime
max-ttl: "168h" # maximum 168h
fetches: 3 # 1..3
grants: # limited publication grants
enabled: true # false - X-CDROP-GRANT -> 403
uses: 3 # default publications per grant
max-uses: 10 # maximum that can be requested
# publish-token: "..." # shared token; prefer CDROP_TOKEN
# metrics-token: "..." # /metrics; prefer CDROP_METRICS_TOKEN
encrypt-bodies: false # AES-256-GCM; key in CDROP_DATA_KEY
web-publish: false # /publish form with captcha
web-quota: # form quotas per IP over 24 hours
publishes-per-day: 20
bytes-per-day: 1048576
active-objects: 50
rate-limit:
publish: 10 # publish/metadata/delete per minute
get: 60 # fetch per minute
quotas: # per token over rolling 24 hours
publishes-per-day: 500 # 0 - unlimited
bytes-per-day: 8388608
active-objects: 200
mode: "pki" # pki | store | pki+store
store: # modes store and pki+store
# dir: "/var/lib/cdrop/store" # empty - <state-dir>/store
max-size: 1073741824 # per file; 8 GiB ceiling
max-total-bytes: 0 # total; 0 - unlimited
max-objects: 0 # 0 - unlimited
default-ttl-days: 0 # 0 - forever
max-ttl-days: 3650 # maximum TTL in days
publish-tokens:
- token-env: "CDROP_TOKEN_REGISTRAR" # env var with the token
scopes: ["cnet"] # cnet | pem | ssh
expires: "2027-01-01T00:00:00Z" # RFC3339; empty - no expiry
disabled: false # disable without removing env
quota: # overrides global quotas
publishes-per-day: 50
bytes-per-day: 1048576
active-objects: 20
# abuse-contact: "mailto:abuse@example.org" # security.txt; empty - 404
log:
level: "info" # flood|trace|debug|info|notice|warn|error|crit
pipe: "stderr" # stderr or file
format: "tint" # tint | logfmt | json
file: "/var/log/cdrop/cdrop.log" # rotating file
file-mode: "0600" # file mode
color-off: true # no ANSI colour
rotate:
enable: true # lumberjack rotation
max-size: 50 # MiB before rotation
max-age: 14 # days to keep
max-backups: 5 # old copies
compress: true # gzip old copies
local-time: true # filenames in local time7.1. Environment variables
| Variable | Purpose |
|---|---|
CDROP_CONFIG |
config path |
CDROP_TOKEN |
shared publish token (any scope) |
CDROP_METRICS_TOKEN |
access token for /metrics |
CDROP_TOKEN_<NAME> |
values for publish-tokens[].token-env |
Tokens are never written to YAML. For systemd use an
EnvironmentFile with mode 0600.
7.2. cnet artifacts
Armored CNET INVITATION, CNET REQUEST,
CNET PASS and other blocks with the CNET
prefix are accepted by armor format. Signature, validity, network and
nested invitations are not checked. kind is
invite/request/passport for the
three known types and cnet otherwise; scope is
cnet. One cnet block per publication.
7.3. Public PEM and SSH
Allowed PEM blocks:
CERTIFICATE, TRUSTED CERTIFICATE,
PUBLIC KEY, RSA PUBLIC KEY,
CERTIFICATE REQUEST, NEW CERTIFICATE REQUEST,
X509 CRL, PKCS7.
A certificate chain is several CERTIFICATE blocks in one
publication; the service checks only the format and total size, not
chain validity.
Private keys and their containers are forbidden:
RSA PRIVATE KEY (PKCS#1), PRIVATE KEY
(PKCS#8), ENCRYPTED PRIVATE KEY,
EC PRIVATE KEY, DSA PRIVATE KEY,
OPENSSH PRIVATE KEY, as well as arbitrary text. Binary
PKCS#12/PFX is not accepted.
SSH: authorized_keys lines of the form
<type> <base64> [comment]. Allowed types are
ssh-rsa, ssh-ed25519,
ecdsa-sha2-nistp256/384/521,
sk-ssh-ed25519@openssh.com,
sk-ecdsa-sha2-nistp256@openssh.com and their
-cert-v01@openssh.com certificate forms. GOST 2012 public
keys ssh-gost2012-256 and ssh-gost2012-512 are
accepted as well (they have no certificate forms). Lines starting with
# are comments. Keys and certificates cannot be mixed.
At most 16 blocks or SSH lines per publication; the total body size
is limited by max-size (16 KiB default, 64 KiB
maximum).
7.4. Limits
| Parameter | Default | Range |
|---|---|---|
| Size | 16 KiB | up to 64 KiB |
| TTL | 24 h | 1 h – 168 h |
| Fetches | 3 | 1–3 |
| Publishes/deletes | 10/min per IP | configurable |
| Fetches | 60/min per IP | configurable |
| Cleanup | 10 min | fixed |
8. Access tokens
8.1. Principle
A token is an opaque bearer secret. It is not a JWT, is not signed and is not derived from a password. The service compares it in constant time and does not store it in the database.
The token grants the right to:
- publish artifacts;
- delete them;
- read metadata.
Fetching by short link is public by possession of the ID.
8.2. Generation
Recommended:
openssl rand -hex 32That is 32 random bytes (256 bits) in hex, 64 characters. The service imposes no minimum format beyond a non-empty string, but 256 random bits is a reasonable minimum.
The scripts/install.sh installer:
- reads
TOKEN_FILEif set; - otherwise generates
openssl rand -hex 32; - writes
/etc/cdrop/cdrop.envwith mode0600 root:root; - with
REUSE_TOKEN=1(default) keeps an existing token.
8.3. Scopes, quotas, expiry
publish-tokens:
- token-env: "CDROP_TOKEN_REGISTRAR"
scopes: ["cnet"]
expires: "2027-01-01T00:00:00Z"
disabled: false
quota:
publishes-per-day: 50
bytes-per-day: 1048576
active-objects: 20scopes—cnet,pemorssh;expires— RFC3339; after expiry the token returns401;disabled— disable without removing from the environment;quota— overrides the globalquotas(zero fields are inherited).
Quotas are counted over a rolling 24 hours by
objects.token_id. The quota check and the insert run in one
transaction; PostgreSQL also takes an advisory lock on
token_id, so concurrent publishes never exceed the
limit.
8.4. Audit identifier
The service computes:
token-id = sha256(token)[:16]
It appears in the publish/delete log and in
objects.token_id. The token itself is never written to the
log or database. A token-id lets you investigate an incident without
revealing the secret.
8.5. Rotation
- Generate a new token.
- Add a second
publish-tokensentry (or temporarily use the sharedCDROP_TOKEN). - Restart the service (env is read at startup).
- Switch systems to the new token.
- Remove the old token from env/config and restart.
Emergency revocation uses the revoked_tokens table
(migration 4), checked on every publish/meta/delete:
cdrop tokenid -token "$TOKEN" # non-secret token-id
cdrop revoke -config cdrop.yaml <id> # revoke immediately, no restart
cdrop revoke -config cdrop.yaml -list
cdrop revoke -config cdrop.yaml -delete <id>disabled: true in the config remains a static option
(requires a restart).
For abuse/takedown:
cdrop takedown -config cdrop.yaml <ID> # object -> 410
cdrop takedown -config cdrop.yaml -token-id <id> # token -> 401
cdrop takedown -config cdrop.yaml -listThe default reason is abuse (-reason);
records are stored in the takedowns table. The policy is in
ABUSE.md. The contact is set by abuse-contact
and published in /.well-known/security.txt.
8.6. Limited publication grants
A grant is a link to the same service that lets the recipient publish the next artifact without a publish token:
https://drop.example/p/cdg_<random token>
Request a grant when publishing:
X-CDROP-GRANT: request=3
The format is <kind> or
<kind>=<uses>. The kind must belong to the
artifact scope:
| Parent scope | Allowed kind |
|---|---|
| cnet | invite, request, passport,
cnet (usually request) |
| pem | pem |
| ssh | ssh-key, ssh-cert |
uses is 1..grants.max-uses; without
=N the grants.uses default is used. Grants are
not tied to cnet and work for PEM/SSH.
The service embeds the full link (with
base-url) into the published artifact:
- cnet armor and PEM - a header after
-----BEGIN ...-----:X-CDROP-GRANT: https://drop.example/p/cdg_...; - SSH (authorized_keys) - a comment line:
# X-CDROP-GRANT: https://drop.example/p/cdg_....
The header is outside the signed cnet bytes and does not break the signature. Unknown PEM headers are ignored by standard parsers; SSH comments are standard.
Grant properties:
- stored in the parent publication row (
objects.grant_*); DELETE, takedown, TTL expiry or drop-head eviction destroy it; - only
sha256(token)is stored; the token itself is in the link; - consuming a use and inserting the child publication are one transaction;
- allows only its own
kind/scope; a foreign scope is403; - does not allow metadata, deletion, the admin panel or the captcha form;
- cannot issue another grant:
X-CDROP-GRANTwith a grant token is403; - expired or exhausted —
410 Gone; - the log path
/p/<token>is redacted to/p/<grant>, and the publication log records a grant id.
Without X-CDROP-GRANT the service behaves exactly as
before: old clients and the API keep working.
9. Binding to system secrets: a cnet registrar example
Goal: a cnet registrar publishes Invitations and Passports to cdrop without access to other scopes.
9.1. Step 1. Token on the cdrop side
On the cdrop server:
umask 077
TOKEN=$(openssl rand -hex 32)
printf 'CDROP_TOKEN_REGISTRAR=%s\n' "$TOKEN" >> /etc/cdrop/cdrop.envIn the cdrop config:
publish-tokens:
- token-env: "CDROP_TOKEN_REGISTRAR"
scopes: ["cnet"]
quota:
publishes-per-day: 100
bytes-per-day: 4194304
active-objects: 50Restart:
systemctl restart cdropVerify that the token is recognized:
set -a; . /etc/cdrop/cdrop.env; set +a
curl -X POST https://drop.example/api/v1/objects \
-H "Authorization: Bearer $CDROP_TOKEN_REGISTRAR" \
-H "X-CDROP-TTL: 1h" \
--data-binary @test-invite.txtA 201 response means the token and scope are configured.
401 — token not found/expired; 403 — scope not
allowed; 422 — the artifact failed validation.
9.2. Step 2. Passing the token to the registrar
A token is as sensitive as a key. Storage options:
- systemd
EnvironmentFile=/etc/cnet/cdrop.env(0600 root); - a
0600file in the registrar configuration directory; - Vault/KMS with a short lifetime;
- an environment variable only for the duration of a command.
Never: git, YAML in a repository, argv (visible in ps),
logs.
9.3. Step 3. Publishing from the registrar
The CLI takes the token from CDROP_TOKEN or
-token:
# Invitation
cnet pki invite new --out invite.txt ...
CDROP_TOKEN="$CDROP_TOKEN_REGISTRAR" \
cdrop publish -config registrar-cdrop.yaml -file invite.txt
# Passport
CDROP_TOKEN="$CDROP_TOKEN_REGISTRAR" \
cdrop publish -config registrar-cdrop.yaml -file pass.txt -qrregistrar-cdrop.yaml contains only the address:
base-url: "https://drop.example"The token is not stored there.
An alternative is a separate env file for the CLI:
install -m 0600 /dev/null /etc/cnet/cdrop.env
printf 'CDROP_TOKEN=%s\n' "$REGISTRAR_TOKEN" > /etc/cnet/cdrop.env
set -a; . /etc/cnet/cdrop.env; set +a
cdrop publish -config registrar-cdrop.yaml -file invite.txt9.4. Step 4. Recipient
cdrop get -config subscriber-cdrop.yaml <ID>
# or open https://drop.example/o/<ID>The recipient verifies the artifact with standard cnet tools:
cnet pki invite show --in invite.txt
cnet pki passport verify --in pass.txt --root root.crt ...cdrop does not replace this verification.
9.5. Step 5. Audit and revocation
In the cdrop log:
published id=... kind=invite scope=cnet token=<token-id>
The token-id shows which system performed the publish.
To revoke:
publish-tokens:
- token-env: "CDROP_TOKEN_REGISTRAR"
disabled: trueand restart. Or remove the line from the env file.
10. Integration
10.1. CLI
cdrop serve -config /etc/cdrop/cdrop.yaml
cdrop check -config /etc/cdrop/cdrop.yaml [-db]
cdrop publish -config cdrop.yaml -file invite.txt [-ttl 24h] [-qr] [-qr-out png]
cdrop publish -config cdrop.yaml -file invite.txt -grant request=3
cdrop get -config cdrop.yaml <ID|URL>
cdrop status -config cdrop.yaml <ID|URL>
cdrop qr -config cdrop.yaml [-out png] <ID|URL>
cdrop version-qr prints a QR code to the console;
-qr-out/-out write a PNG.
10.2. cnet pki
Currently:
cnet pki invite new ... --out invite.txt
cdrop publish ... -file invite.txtPlanned: cnet pki invite new --publish and
passport show --publish, so publishing is a single
command.
10.3. Android
The cnet issuer may gain a “share an invitation via cdrop” mode. This
requires the INTERNET permission and explicit cdrop
enablement; when disabled there are no network calls.
10.4. Other systems
Any system with a token can publish over HTTP:
curl -X POST https://drop.example/api/v1/objects \
-H "Authorization: Bearer $TOKEN" \
-H "X-CDROP-TTL: 12h" \
-H "X-CDROP-GRANT: request=3" \
--data-binary @artifact.txtThe response contains grant.url - the full link for the
recipient. To publish with the grant:
curl -X POST https://drop.example/api/v1/objects \
-H "Authorization: Bearer cdg_..." \
--data-binary @request.txtFor a scoped token, use it in Authorization; the scope
must match the artifact scope.
11. Deployment
11.1. Requirements
- Linux with systemd (tested on Debian 13);
- outbound access for Let’s Encrypt (with TLS_MODE=letsencrypt);
- PostgreSQL 17 or SQLite;
- ports 80/443 open (or other ports);
- passwordless sudo for the installer (or a root login).
11.2. Installer
cd cdrop
cp scripts/install.env.example scripts/install.env
$EDITOR scripts/install.env
./scripts/install.sh scripts/install.envinstall.env is never committed to git.
Main fields:
SSH_HOST="drop.example"
DOMAIN="drop.example"
BASE_URL="https://drop.example"
DB="postgres"
TLS_MODE="letsencrypt" # letsencrypt|files|off
TOKEN_FILE="" # empty - generate
REUSE_TOKEN="1"What the script does:
- builds the executable (
CGO_ENABLED=0,-trimpath -s -w); - prepares config, env, unit, hook, cron and backup;
- runs a preflight check before stopping the service;
- copies files over ssh/scp;
- creates the user, directories and the PostgreSQL role/database;
- issues a certificate (standalone on first start);
- starts the service and checks
/healthz; - rolls the executable back from
cdrop.prevon failure.
11.3. TLS
letsencrypt— certbot, webroot/var/www/certbotfor renewal;files— ready-madefullchain.pem/privkey.pem;off— HTTP only (for debugging, not for a public node).
Renewal: /etc/cron.d/cdrop-certbot twice a day; the
deploy hook copies the certificate and runs
systemctl reload cdrop (SIGHUP, no downtime).
11.4. PostgreSQL
The installer creates the role and database through peer-auth:
su - postgres -c 'createuser --login cdrop'
su - postgres -c 'createdb -O cdrop cdrop'DSN:
host=/var/run/postgresql dbname=cdrop user=cdrop sslmode=disable
The schema and migrations are created at startup: the
schema_migrations table stores versions 1–6 (base schema,
network → scope, token_id,
revoked_tokens). The rename migration is irreversible. The
installer makes a pg_dump into
/var/backups/cdrop/pre-upgrade-*.dump before an
upgrade.
11.5. Post-install checks
curl -I http://drop.example/ # 301 to https
curl https://drop.example/healthz # ok
curl --resolve drop.example:443:127.0.0.1 \
https://drop.example/metrics # locally
cdrop check -config /etc/cdrop/cdrop.yaml
# -db requires peer-auth: run as the cdrop user
su - cdrop -s /bin/sh -c \
'cdrop check -db -config /etc/cdrop/cdrop.yaml'
systemctl status cdrop11.6. Upgrade and rollback
A repeated install.sh is idempotent: config/unit are
updated, the token is preserved and the executable is replaced. On a
failed health check, cdrop.prev is restored
automatically.
The installer keeps cdrop.prev,
cdrop.yaml.prev and cdrop.service.prev; on a
failed health check all three are restored. Manual rollback:
install -m 0755 /usr/local/bin/cdrop.prev /usr/local/bin/cdrop
install -m 0644 /etc/cdrop/cdrop.yaml.prev /etc/cdrop/cdrop.yaml
install -m 0644 /etc/systemd/system/cdrop.service.prev \
/etc/systemd/system/cdrop.service
systemctl daemon-reload
systemctl restart cdrop11.7. Backup and restore
Every day /usr/local/sbin/cdrop-backup makes a
pg_dump into /var/backups/cdrop and keeps it
for 7 days. Once a week /usr/local/sbin/cdrop-backup-verify
restores the latest dump into a temporary database, checks the
objects table and drops it.
Restore:
su - cdrop -s /bin/sh -c \
'pg_restore --clean --no-owner -d cdrop /var/backups/cdrop/<file>'
systemctl restart cdrop11.8. Releases
make -C cdrop distBuilds linux/amd64 and linux/arm64:
dist/cdrop-<version>-linux-amd64.tar.gz
dist/cdrop-<version>-linux-arm64.tar.gz
dist/*.sha256
The archive contains the executable,
config.example.yaml, deploy/, the installer,
healthcheck, SPEC, README, the manual (Markdown RU/EN, PDF and
self-contained HTML), OpenAPI and BUILD.txt
(version/git/built_at/os/arch). Verify with
sha256sum -c.
Manual sources are cdrop-manual-ru.md and
cdrop-manual-en.md; make -C cdrop manual
builds the PDF and self-contained HTML pages that are embedded in the
executable and served at /cdrop/manual and
/ru/cdrop/manual. The HTML is also installed into
/usr/share/doc/cdrop (root) or
~/.local/share/doc/cdrop (user/portable). pandoc is not
required on the VPS.
11.9. Non-root installation and TUI
A 3×3 matrix: installation mode × TLS.
| Mode | systemd | Paths | Ports | TLS |
|---|---|---|---|---|
| root | system | /etc/cdrop, /var/lib/cdrop |
80/443 | letsencrypt/files/off |
| user | systemd --user + linger |
~/.config/cdrop, ~/.local/state/cdrop |
8080/8443 | files/off |
| portable | none | ~/.cdrop |
8080/8443 | files/off |
root:scripts/install.sh(over ssh/scp).user:scripts/install-local.sh,MODE=user;loginctl enable-linger $USERso the service survives logout.portable:MODE=portable,run.sh/stop.sh, no systemd.- A user service cannot bind ports <1024: use high ports and a reverse proxy.
letsencryptis available only in root mode.- TUI:
scripts/install-tui.sh, dialogs viacdialogfrom the releasebin/archive or the systemdialog. - The public host runs exactly one service instance on port 443; extra
instances are not used. Port 80 belongs to the same process: HTTPS
redirect and ACME webroot. Certificate renewal is handled by the
cdrop-cert-hookdeploy hook plus a service reload.
11.10. Removal
There is no automatic uninstall. The order is:
systemctl disable --now cdrop
rm -f /etc/systemd/system/cdrop.service
rm -f /etc/cron.d/cdrop-certbot /etc/cron.d/cdrop-backup
rm -f /usr/local/bin/cdrop /usr/local/bin/cdrop.prev
rm -f /usr/local/sbin/cdrop-cert-hook /usr/local/sbin/cdrop-backup
rm -rf /etc/cdrop /var/lib/cdrop
userdel cdrop
su - postgres -c 'dropdb cdrop'
su - postgres -c 'dropuser cdrop'
systemctl daemon-reload12. Operations
12.1. Log
By default the log is written to a rotating file (lumberjack) and also to stderr/journald. Paths:
- root:
/var/log/cdrop/cdrop.log; - user/portable:
~/.local/state/<service>/logs/cdrop.log.
Defaults: 50 MiB, 5 copies, 14 days, gzip, 0600. This
protects the VPS from running out of space. color-off is
enabled.
The log contains:
published/fetched/deletedwith id, kind, scope, size, sha256, token-id;trimwhen old rows are evicted;requestwith method/path/status/duration;- validation errors — locally, without the artifact body.
Artifact bodies and tokens are never logged.
12.2. Metrics
/metrics (loopback or
CDROP_METRICS_TOKEN):
cdrop_published_total{kind};cdrop_fetched_total{kind};cdrop_gone_total;cdrop_rejected_total;cdrop_deleted_total;cdrop_quota_rejected_total;cdrop_active-objects;cdrop_requests_total{method,status};cdrop_request_duration_seconds(histogram);cdrop_tls-cert_expires_seconds/cdrop_tls-cert_expires_in_seconds;cdrop_build_info{version}.
Every HTTP request is limited to 10 seconds, including database
operations. Responses include X-Request-ID (incoming or
generated), which is also written to the log. PostgreSQL uses a pool of
10/5 with a 30-minute lifetime and 5-minute idle time.
12.3. Healthcheck and alerts
/usr/local/sbin/cdrop-healthcheck runs from cron every 5
minutes:
- checks
/healthzand/readyz; - checks the TLS certificate lifetime (threshold
CDROP_CERT_WARN_DAYS, 14 days by default); - on failure writes to journald and, if
CDROP_ALERT_WEBHOOKis set, sends JSON to the webhook; - exits with code 1.
Prometheus rules — deploy/alerts.yml: down, not ready,
5xx, 429, quota, certificate < 14 and < 3 days.
Behind a reverse proxy the loopback check is useless: requests with
X-Forwarded-For/Forwarded are not considered
loopback. Set CDROP_METRICS_TOKEN or a separate
listener.
12.4. Resources
The service is undemanding: one process, SQLite/PostgreSQL, static
pages. The main consumers are the database and TLS. Under growing load,
watch the PostgreSQL pool and the token_id/created_at
indexes.
13. Security
- Publishing via API/CLI, deletion and metadata require a token.
- The
/publishweb form (when enabled) allows anonymous publishing after a captcha; per-IP limits and quotas apply. - Tokens are compared in constant time and never logged.
- Scopes and quotas limit the damage from a leaked token.
400/422/403errors are deliberately generic./metricsis closed; metadata requires a token.- The service forbids private keys and is not meant for secrets; signatures and chains are not verified.
- TLS is mandatory; HSTS, no-store, noindex, no-referrer, CSP.
- The body is served as escaped
text/plain/HTML;nosniff. - Limits: TTL, size, fetch count, rate limit, quotas.
- systemd: separate user,
StateDirectory,CAP_NET_BIND_SERVICE,ProtectSystem=strict,PrivateTmp,PrivateDevices,ProtectClock/KernelLogs/Hostname,RestrictNamespaces/Realtime/SUIDSGID,UMask=0077,PrivateMounts,ProtectProc=invisible,ProcSubset=pid,RemoveIPC,SystemCallFilter=@system-service. - Three fetches is best-effort: the recipient can save the text.
- The ID is 60 bits; brute force is limited by the rate limit.
14. API contract
cdrop/openapi.yaml describes all public endpoints, the
PublishResponse, Metadata and
Error schemas, the ID parameter and the Bearer token. YAML
correctness is checked by the server/openapi_test.go
test.
15. Tests and quality
make -C cdrop check # vet + check-core + test
make -C cdrop cover # coverage
make -C cdrop fuzz # short fuzz runsCoverage (0.22.0):
| Package | Coverage |
|---|---|
cmd/cdrop |
73% |
store |
82% |
artifact |
95% |
internal/config |
91% |
server |
88% |
secret |
66% |
Integration suite: make -C cdrop test-integration
(install, bwrap-E2E, user-E2E, portable-E2E,
go test ./...).
Installer generation is checked by
make -C cdrop test-install (dry-run, config, remote.sh,
token permissions). The full E2E in bwrap is
make -C cdrop test-e2e: file layout, repeated installation,
token preservation and rollback on a failed restart (systemd and
PostgreSQL are replaced with stubs).
PostgreSQL tests are enabled with
CDROP_TEST_POSTGRES_DSN. Fuzz targets: the PEM validator
and NormalizeID.
16. Limitations and roadmap
Limitations:
- not a CA and does not verify the artifact for the recipient;
- not for secrets or private keys;
- the fetch limit does not guarantee confidentiality;
- quotas are best-effort;
- when the database is full, the oldest active publications are evicted;
- signatures and chains are not verified;
- one service instance per host.
Near-term directions:
- alerts for 5xx/429/DB/certificate;
- container E2E for the installer;
cnet pki --publishand Android integrations;- metrics by artifact class.
17. Appendices
Sections 17.2 and 17.3 are control lists: [x] marks a
completed check. They are checklists, not technological charts:
operations, modes and time norms are not described here.
17.1. Minimal config
listen: ":8080"
base-url: "http://127.0.0.1:8080"
state-dir: "./state"
db: "sqlite"
publish-token: "dev-token"
fetches: 3
log:
level: "info"
pipe: "stderr"
format: "logfmt"