mosskeys
Authenticated write path

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:

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

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

shell
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

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

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

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)

javascript
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

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" => "..."}
The JavaScript rung of the SDK can also compute the public material for you via the WASM 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:

shell
curl -X POST https://mosskeys.com/api/acme/log/checkpoints \
  -H "Authorization: Bearer $MOSSKEYS_TOKEN"

Response 200 OK:

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:

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

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

Because BYOK signing is fiddly to get right by hand, most callers use the 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
{ "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

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

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

powershell
iwr https://mosskeys.com/install.ps1 -useb | iex   # [coming soon]

Any platform with Rust

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

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

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

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

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

shell
# 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:

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

shell
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).

shell
# 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 mosskeys example, and a link to the token UI.