Publish from your backend or agent
How to append public key material and publish client-signed checkpoints from a service or
an autonomous agent, over plain HTTP or with the mosskeys
CLI. If you only need to read
keys and verify proofs, use the
public read and verify API
instead: it needs no token.
Zero-knowledge guarantees
The write path is designed so that secrets never leave your infrastructure:
- Only already-public material crosses the wire: public keys, the tree size and root, and checkpoint notes you have already signed locally with your own key.
- The server never signs. Checkpoint signing is Bring-Your-Own-Key (BYOK) and happens entirely on your side. The server only verifies a signed note and checks that it commits to the log's current head before persisting it.
- Your signing key is read locally, used locally, and never transmitted or logged.
Authentication
All write endpoints sit behind a bearer-token pipeline. Create a token from the
in-product token UI at Settings → API tokens.
A token looks like msk_live_….
Scope it to a single namespace (recommended) or leave it account-wide, and grant only the capabilities it needs:
| Capability | Grants |
|---|---|
| append | POST /api/:slug/log/entries |
| checkpoint | POST /api/:slug/log/checkpoints (two-phase signing) |
Send it on every request:
Authorization: Bearer msk_live_…
Token hygiene.
Keep tokens in a secret store or an environment variable
(MOSSKEYS_TOKEN). Never commit or log them.
Prefer the narrowest scope and capability set per token, and rotate or revoke from
the same settings page the moment a token is no longer needed or may be exposed.
Append a key
POST /api/:slug/log/entries
requires the append
capability.
The body is all public material your SDK already produced at key generation. Each key is base64:
| Field | Required | Notes |
|---|---|---|
| label | yes | Lookup handle: email, username, or device id. |
| enc_x25519 | yes | Public X25519 encryption key. |
| enc_pq | yes | Public ML-KEM (post-quantum) encryption key. |
| signing_pub | yes | Public signing key. |
| ts_ms | no | Client timestamp (ms since epoch). |
Response 200 OK:
{ "index": 0, "size": 1, "root": "<base64>" }
Idempotency & retries
Append is dedup-idempotent
on the derived content: re-posting identical material returns the same
index
with 200, never a duplicate leaf and never an
error. This makes the endpoint safe under at-least-once delivery, so you can retry
freely on network failures without special idempotency keys.
Append: examples
curl
curl -X POST https://mosskeys.com/api/acme/log/entries \
-H "Authorization: Bearer $MOSSKEYS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label":"alice@example.com","enc_x25519":"<base64>","enc_pq":"<base64>","signing_pub":"<base64>"}'
Ruby
require "net/http"
require "json"
require "uri"
uri = URI("https://mosskeys.com/api/acme/log/entries")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch('MOSSKEYS_TOKEN')}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(
label: "alice@example.com",
enc_x25519: enc_x25519,
enc_pq: enc_pq,
signing_pub: signing_pub
)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
raise "append failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
puts JSON.parse(res.body) # => {"index"=>0, "size"=>1, "root"=>"..."}
Python
import os, requests
res = requests.post(
"https://mosskeys.com/api/acme/log/entries",
headers={"Authorization": f"Bearer {os.environ['MOSSKEYS_TOKEN']}"},
json={
"label": "alice@example.com",
"enc_x25519": enc_x25519,
"enc_pq": enc_pq,
"signing_pub": signing_pub,
},
timeout=30,
)
res.raise_for_status()
print(res.json()) # {"index": 0, "size": 1, "root": "..."}
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func main() {
body, err := json.Marshal(map[string]any{
"label": "alice@example.com",
"enc_x25519": encX25519,
"enc_pq": encPQ,
"signing_pub": signingPub,
})
if err != nil {
panic(err)
}
req, err := http.NewRequest(http.MethodPost,
"https://mosskeys.com/api/acme/log/entries", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("MOSSKEYS_TOKEN"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
panic(fmt.Sprintf("append failed: %s", res.Status))
}
fmt.Println("status:", res.Status)
}
JavaScript (Node / fetch)
const res = await fetch("https://mosskeys.com/api/acme/log/entries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MOSSKEYS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "alice@example.com",
enc_x25519: encX25519,
enc_pq: encPQ,
signing_pub: signingPub,
}),
});
if (!res.ok) throw new Error(`append failed: ${res.status}`);
console.log(await res.json()); // { index: 0, size: 1, root: "..." }
Elixir
# Req is the recommended HTTP client for Elixir. `Req.post!/2` raises on a
# transport error and returns a %Req.Response{}; match on the status.
%{status: 200, body: body} =
Req.post!("https://mosskeys.com/api/acme/log/entries",
auth: {:bearer, System.fetch_env!("MOSSKEYS_TOKEN")},
json: %{
label: "alice@example.com",
enc_x25519: enc_x25519,
enc_pq: enc_pq,
signing_pub: signing_pub
}
)
body
# => %{"index" => 0, "size" => 1, "root" => "..."}
metamorphic-crypto
npm package. The other languages POST the public halves they already hold.
Publish a checkpoint (two-phase, BYOK)
A checkpoint is a client-signed
commitment to the log's current head. The server never signs: you sign locally with
your own key and the server verifies. The handshake is two phases against the
same
endpoint,
POST /api/:slug/log/checkpoints
(requires the checkpoint
capability and a plan that enables checkpoint signing).
Phase 1 — fetch the material to sign
Send the request with
no note_text:
curl -X POST https://mosskeys.com/api/acme/log/checkpoints \
-H "Authorization: Bearer $MOSSKEYS_TOKEN"
Response 200 OK:
{ "origin": "…", "name": "…", "size": 42, "root": "<base64>" }
Sign offline
Produce a C2SP checkpoint note over that head using your own signing key. Nothing about
the key touches the network. Sign the dual-line note (the hybrid post-quantum line plus
a classical Ed25519 0x01 line under the origin
name, one call in the SDKs: Checkpoint.sign_dual, checkpointSignDual, or mosskeys checkpoint) so stock C2SP witness
software can verify and cosign your checkpoints. Single-line hybrid notes are still
accepted; verifiers ignore whichever lines they do not trust.
Phase 2 — publish the signed note
Send the request
with note_text:
curl -X POST https://mosskeys.com/api/acme/log/checkpoints \
-H "Authorization: Bearer $MOSSKEYS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note_text":"<your locally-signed C2SP note>"}'
Response 201 Created:
{ "origin": "…", "size": 42, "root": "<base64>", "note": "…" }
The server verifies the signature against the namespace's public key and asserts that
the note commits to the log's
current
head. If the head advanced since Phase 1 you get 409 head_mismatch: fetch fresh material and
re-sign. Publishing is idempotent on (namespace, size).
mosskeys
CLI (below) for checkpoints and reserve raw HTTP for appends.
Policies and attestation
A namespace's policy is its log's public promise of a security profile: the security level, checkpoint signature suite, and directory backend it runs under. The namespace signing key is generated to match that profile, and attesting signs the promise into the log with that key.
Attestation is a browser ceremony on the namespace page (Policy → Attest policy): the
server verifies the envelope and refuses it loudly if the key does not match the
declared profile. Once attested, every checkpoint you publish is enforced against it: a
note signed by the wrong key or under a different profile is rejected with 422 unprocessable. The CLI path is no different:
sign with the same namespace key the policy was attested under.
Changing the profile appends a new, append-only policy version (old versions are never edited). The directory backend changes freely. A security level or signature suite your current key cannot sign is a key rotation, bound into the same ceremony on the namespace page (Policy → Change security profile): the browser generates a fresh key for the new profile, seals it under your account key, and records the old-to-new rotation in the log, then you attest the new version with the new key. The new version is appended unattested, so checkpointing keeps working throughout.
If you sign checkpoints locally (the CLI), re-export or replace the key after a rotation: notes signed with the retired key are rejected for new checkpoints, while checkpoints from before the rotation stay verifiable under it, since the retired key stays fetchable from the log's rotation record. Witnesses registered for this log need the fresh verifier-key bundle (the namespace page's Integrate tab) after a rotation.
Errors
Every error on the write path uses one canonical envelope:
{ "error": { "code": "…", "message": "…" } }
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or invalid token. |
| 403 | forbidden | Token lacks the capability, is not scoped to this namespace, or the plan disallows checkpoint signing. |
| 404 | not_found | Unknown slug (or not owned by this token's owner; not distinguished, to avoid leaking existence). |
| 409 | head_mismatch | Signed checkpoint does not commit to the current head. Re-fetch material and re-sign. |
| 422 | invalid_request / reserved_label / unprocessable |
Malformed body, a label in the reserved _ namespace
(mosskeys-internal log records), or a checkpoint note that failed verification or
the namespace's attested-policy checks (the signing key must match the policy's
declared security profile).
|
| 429 | rate_limited / quota_exceeded |
Per-token rate limit or per-account monthly append quota exhausted. Honor the
Retry-After
header (seconds).
|
Rate limits and monthly append quota come from your plan's entitlements. Treat
429
(respect Retry-After) and 5xx/transport errors as
retryable
with bounded exponential backoff. Treat 4xx
other than 409/429
as terminal.
Install
The mosskeys
CLI is a single static binary (Rust). The install / package name is mosskeys-cli; the command it installs is mosskeys. Prebuilt binaries are published for
macOS (arm64/x64), Linux (x64/arm64, incl. musl), and Windows (x64).
macOS
brew install moss-piglet/mosskeys-cli/mosskeys-cli # installs the `mosskeys` binary
Homebrew installs the prebuilt, signed binary from the tap moss-piglet/mosskeys-cli. The fully-qualified name trusts and installs just that formula (Homebrew 6+ requires explicit trust for third-party taps). You can also
cargo install
below or grab a signed binary from the
GitHub releases.
Linux
Grab a signed binary from the
GitHub releases
(x64/arm64, incl. musl), or use
cargo install
below. A one-line install script is planned:
curl -fsSL https://mosskeys.com/install.sh | sh # [coming soon]
Windows (PowerShell)
Grab the signed x86_64-pc-windows-msvc
binary from the
GitHub releases
, or use
cargo install
below. A one-line install script is planned:
iwr https://mosskeys.com/install.ps1 -useb | iex # [coming soon]
Any platform with Rust
cargo install mosskeys-cli --locked # any platform with Rust; installs the `mosskeys` binary
Use --locked
so the build uses the versions pinned in the published Cargo.lock. The crypto core depends on
pre-release RustCrypto curve crates, and a fresh resolve without
--locked
can pick an incompatible newer transitive release. The Homebrew and
prebuilt binaries are already built and are unaffected.
Verify what you downloaded
Verification is part of the security profile: mosskeys is a zero-knowledge, BYOK tool, so
the CLI itself is auditable and every release artifact is signed. Confirm the checksum and
the keyless cosign
signature (identity = the public GitHub Actions release workflow) before running it:
# every release artifact ships with a SHA-512 checksum, a keyless cosign
# signature, and a SLSA build-provenance attestation.
sha512sum -c SHA512SUMS
cosign verify-blob \
--bundle mosskeys-<version>-<target>.tar.gz.cosign.bundle \
--certificate-identity-regexp 'https://github.com/moss-piglet/mosskeys-cli/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
mosskeys-<version>-<target>.tar.gz
CLI quickstart
The mosskeys
CLI wraps this API and performs local BYOK checkpoint signing. It shares one audited crypto
core with the web (WASM) and Elixir (NIF) surfaces. Install it from the
Install
section above, then configure your credentials.
Configure credentials
mosskeys config set-token # paste msk_live_… (reads stdin; stored 0600, never logged)
mosskeys config set-namespace acme # default namespace slug
mosskeys config show # token is always redacted
Config lives at ~/.config/mosskeys/config.toml.
For unattended agents, skip the file entirely and export environment variables instead:
export MOSSKEYS_TOKEN=msk_live_…
export MOSSKEYS_BASE_URL=https://mosskeys.com # optional override
Generate a key set
mosskeys keygen
creates a key set for a member entirely on your machine. It is
fully offline
and never contacts the server, so --security-level
is operator-supplied — pass cat3
or cat5
to match your namespace's security profile.
# fully offline — never contacts the server. --security-level is operator-supplied,
# so match your namespace's security profile (cat3 or cat5).
mosskeys keygen \
--namespace acme \
--security-level cat3 \
--label alice@example.com
# write the private halves to a file (chmod 0600, refuses to overwrite);
# only the PUBLIC halves are printed to stdout, ready to publish.
mosskeys keygen -n acme --security-level cat5 --label alice@example.com --out ./alice.keys
# other flags: --namespace-id <uuid>, --dry-run (validate only), --json (machine-readable)
mosskeys keygen -n acme --security-level cat3 --label alice@example.com --json
This is BYOK by construction: the
private
halves are written to a chmod 0600
file (keygen refuses to overwrite an existing file), and only the three
public
halves — enc_x25519, enc_pq, and
signing_pub
— are printed to stdout and ever published. Output is byte-for-byte interoperable with the
in-browser
Generate locally
flow, so you can mix the CLI and the web app freely. The printed public halves feed the
Publish
step below.
Publish
# single entry
mosskeys publish \
--label alice@example.com \
--enc-x25519 <base64> --enc-pq <base64> --signing-pub <base64>
# from a JSON file or piped stdin (object or array)
mosskeys publish --file ./keys.json
cat keys.json | mosskeys publish
# validate without sending
mosskeys publish --file ./keys.json --dry-run
Sync (daemon)
Continuously publishes from a JSON source, at-least-once (server dedup keeps it
idempotent), with bounded exponential backoff that honors Retry-After:
mosskeys sync --source ./keys.json # long-running
mosskeys sync --source ./keys.json --once # single pass, then exit
Checkpoint (local BYOK signing)
Runs the full two-phase handshake for you: fetch head, sign offline with your key, publish.
mosskeys checkpoint --key ./checkpoint.key
mosskeys checkpoint --key ./checkpoint.key --watch 3600 # re-sign on a cadence
mosskeys checkpoint --key ./checkpoint.key --dry-run # print the note, don't publish
Verify (read-only, no token)
Proves a label's key history is append-only and tamper-evident (inclusion, consistency, and witness co-signatures), online against a namespace or fully offline from a pinned checkpoint. See the read and verify API for the full flow and honest scope (it does not, alone, detect a split view).
# read-only, no token: verify a label's key history (inclusion + consistency
# + witness co-signatures) against the signed checkpoint.
mosskeys verify -n acme --label alice@example.com
# offline: verify a pinned checkpoint note you saved earlier (no network).
mosskeys verify --checkpoint ./pinned-checkpoint.note --verifier-key "<vkey>"
# supply-chain: prove an artifact/leaf digest is committed at a given index.
mosskeys verify -n acme --digest <hex> --index 42 --json
Every command accepts --json
for machine-readable stdout and maps the API error taxonomy to stable exit codes, so
scripts and agents can branch reliably. NO_COLOR, non-TTY, and
--json
all suppress styling automatically.
Advanced:
prefer to build from source? Clone the public
mosskeys-cli
repository and run cargo build --release # → target/release/mosskeys.
See also
- Public read and verify API: fetch a key with an inclusion proof and verify it against a signed checkpoint (no auth required).
-
The Integrate
section on your namespace page surfaces the endpoints for your slug, a copy-pasteable
curl and
mosskeysexample, and a link to the token UI.