Skip to content

Cloudoak registry protocol (v2)

A registry is a catalogue of signed app definitions. Nodes read it anonymously over HTTPS. Publishers write to it with signed requests from the cloudoak CLI. The registry never holds a user, a node identity, a credential, a price or a usage figure.

The registry is not trusted for content. Every manifest is signed by its publisher, and everything the registry itself says (who owns an app, which versions exist, what is yanked) is signed by a registry key the node already holds. A node refuses what does not verify and keeps running what it has installed.

v2 is a hard cut from v1: the paths, the documents and the key id form all changed, and nothing reads v1.

Rules are numbered R1, R2, … . Each one has at least one case in spec/registry-vectors/, which names the rules it exercises.

  • R1. Canonical form is RFC 8785 (JCS): object members sorted by UTF-16 code units, no insignificant whitespace, numbers in the shortest ECMAScript form, strings with only the required escapes. Duplicate member names are an error.
  • R2. Hashes are SHA-256, written as 64 lowercase hex characters. manifest_sha256 is the hash of the manifest’s canonical bytes.
  • R3. A key is an Ed25519 public key, written base64url without padding. Its key id is the hash of its 32 raw bytes: 64 lowercase hex characters. Ids are compared only in that full form. A short id is the first 16 characters, for people to read beside the full one; nothing compares a short id, and a 16-character id given as configuration is refused with a message saying why.
  • R4. A signature is Ed25519 over the canonical bytes of the signed object, written base64url without padding.
  • R5. Times are RFC 3339 in UTC with a Z and whole seconds (2026-10-20T10:00:00Z).
  • R6. Versions are semantic versions (MAJOR.MINOR.PATCH, optional pre-release, no build metadata) compared by semver precedence.
  • R7. A signed document is {"<name>": {…}, "signatures": [{"key_id": "<64 hex>", "sig": "…"}]}. The signatures cover the canonical bytes of the inner object alone. <name> is fixed per document (section 4).
  • R8. A verifier checks every listed signature. A document with no signatures, a signature by a key id it does not hold, or one that does not verify, is refused. Extra members beside <name> and signatures are refused.
  • R9. A publisher’s signed manifest is the same envelope with the name manifest: {"manifest": {…}, "signatures": […]}.

Documents are named by paths, all under v2/:

Path Name inside Signed by
v2/meta.json — (unsigned) nobody; its moved_to is root-signed
v2/keys.json keys the root key
v2/catalog.json catalog an online key
v2/apps/{app_id}/index.json app an online key
v2/apps/{app_id}/versions/{version}.json — the publisher, inside
v2/publishers/{publisher}.json publisher an online key
v2/submissions/{submission_id}.json — (unsigned) nobody
  • R10. A registry base is one of two forms. A plain base is a URL prefix: a document’s URL is the base, a / if the base does not end with one, and the path. A base template is a URL containing {id}: a document’s URL is the template with {id} replaced by the document id.
  • R11. A document’s id is the first 32 hex characters of the SHA-256 of its path, as UTF-8, with no leading /. For example the id of v2/catalog.json is the first 32 characters of sha256("v2/catalog.json").
  • R12. Bases are https. Plain http is allowed only for localhost, 127.0.0.1 and ::1, and a node may read a registry folder on its own machine as a plain file:// base. A node follows a redirect only to the same scheme and host, and at most three times.
  • R13. Nodes read anonymously: no cookie, no credential, no node identity is ever sent.
  • R14. A base may send ETag. A node sends If-None-Match when it has one, and a 304 means its stored copy stands.
{
"protocol": 2,
"name": "Cloudoak registry",
"submit": "https://…",
"ping": "https://…",
"terms": {"version": "1", "url": "https://…"},
"catalog_sequence": 12,
"moved_to": {"moved_to": {"bases": ["https://…"], "at": "…"}, "signatures": [ … ]}
}
  • R15. protocol is 2; any other value is refused.
  • R16. catalog_sequence is an unsigned hint. A node fetches catalog.json only when the hint is higher than the sequence it holds, or when it holds none; whatever it fetches is still verified. A missing or wrong hint can only delay an update, never cause a rollback.
  • R17. moved_to is a root-signed envelope naming new bases with a date. A node that verifies it against a root key it holds adds those bases, tries them first, and stores them. A moved_to that is unsigned, or signed by any other key, is ignored and logged.
{"keys": {"issued_at": "…", "keys": [
{"key_id": "<64 hex>", "public_key": "…", "not_before": "…", "not_after": "…"}
]}, "signatures": [{"key_id": "<root key id>", "sig": "…"}]}
  • R18. keys.json must verify against a root key the node holds: compiled in for the default registry, or listed in CLOUDOAK_REGISTRY_KEYS for another one.
  • R19. Only a key listed here, used inside its not_before and not_after, may sign a catalogue, app index or publisher record. A document signed by an expired or not-yet-valid key is refused.
  • R20. Each key’s key_id must be the id of its public_key (R3).
{"catalog": {"sequence": 12, "generated_at": "…", "apps": [
{"app_id": "com.example.notes", "name": "Notes", "publisher": "example",
"publisher_verified": false, "summary": "…", "categories": ["…"],
"homepage": "…", "latest": "1.2.0", "min_node": "0.6.0",
"sequence": 12, "updated_at": "…"}
]}, "signatures": [ … ]}
  • R21. sequence rises by exactly one with every accepted change, and generated_at is the time of that change, so the same sequence always renders to the same bytes. A node refuses a catalogue whose sequence is lower than the one it holds (no rollback). An equal sequence with different bytes is refused too.
  • R22. Only listed apps appear. Each entry’s sequence is the catalogue sequence at which that app’s index last changed. latest is the app’s highest version that is not yanked. Apps are sorted by app_id.
{"app": {"app_id": "…", "publisher": "…", "status": "listed",
"status_reason": "", "sequence": 12,
"owner_keys": [ … ownership log … ],
"versions": [{"version": "1.2.0", "manifest_sha256": "…",
"published_at": "…", "yanked": false, "yank_reason": "", "min_node": ""}]
}, "signatures": [ … ]}
  • R23. status is listed or removed; a removed app has a status_reason, and a node installs nothing from it.
  • R24. An index whose sequence is lower than the catalogue entry that names it is refused (an old index served beside a new catalogue).
  • R25. Versions are sorted by version precedence, oldest first. yanked and yank_reason live here and only here.

apps/{app_id}/versions/{version}.json (never changes)

Section titled “apps/{app_id}/versions/{version}.json (never changes)”
{"manifest": {…}, "manifest_sha256": "…",
"signatures": [{"key_id": "…", "sig": "…"}], "publisher_key": "…"}
  • R26. A version document never changes once published, and carries no yank state. Its manifest_sha256 must equal the hash the signed index lists for that version, and the hash of the canonical manifest.
  • R27. The manifest’s signatures must verify against publisher_key, whose id must be the current owner key (R30).

publishers/{publisher}.json (online-signed)

Section titled “publishers/{publisher}.json (online-signed)”
{"publisher": {"publisher": "example", "keys": [
{"key_id": "…", "public_key": "…", "added_at": "…", "revoked_at": ""}],
"domain": "example.com", "verified": true}, "signatures": [ … ]}
  • R28. verified is true only while the domain’s https://<domain>/.well-known/cloudoak-publisher.json lists one of the publisher’s current key ids (section 6).

submissions/{submission_id}.json (unsigned)

Section titled “submissions/{submission_id}.json (unsigned)”
{"id": "<64 hex>", "action": "publish", "app_id": "…", "version": "…",
"state": "accepted", "code": "accepted", "reason": "…", "decided_at": "…"}
  • R29. state is accepted, in_review or rejected, with a code from section 7 and a reason a person can act on. Nothing in review is visible anywhere else.

Each app index carries owner_keys, the ownership log:

{"seq": 2, "kind": "rotate", "key_id": "…", "public_key": "…",
"publisher": "example", "at": "…", "by_previous": "…"}
  • R30. The current owner key of an app is the key of the last entry in its log. Only that key may publish, yank or unyank the app’s versions, rotate its key or start a transfer.
  • R31. The first entry has seq 1 and kind claim. Each later entry has seq one higher than the one before.
  • R32. by_previous is the previous owner key’s signature over the canonical bytes of the entry without by_previous. Entries of kind rotate and transfer must carry it.
  • R33. An entry of kind recover has no by_previous: the old key is lost. It is added only after review.
  • R34. A node that has a different key pinned walks the log from its pinned key to the current one. It follows rotate and transfer steps whose by_previous verifies. A walk that meets a recover step is not followed automatically: the upgrade waits for the node’s owner, with the reason shown. A log that does not contain the pinned key at all is refused.

POST {meta.submit} with:

{
"request": {"action": "publish", "app_id": "com.example.notes",
"version": "1.2.0", "publisher": "example", "key_id": "<64 hex>",
"terms_version": "1", "issued_at": "2026-10-20T10:00:00Z", "nonce": "…"},
"request_sig": "…",
"public_key": "…",
"signed_manifest": {"manifest": {…}, "signatures": […]}
}

Every request has action, publisher, key_id, issued_at and a random nonce; every action but verify-domain names an app_id; terms_version is the version of the terms the publisher accepts. Actions and their extra members:

Action In request Beside request
publish version signed_manifest
yank, unyank version, reason (yank) —
rotate-key new_key_id, new_public_key new_key_sig (the new key’s signature over the canonical request), entry_sig
transfer to_key_id, to_public_key, to_publisher entry_sig
accept-transfer — (signed by the receiving key) —
verify-domain domain —

The registry checks a submission in this order, and stops at the first failure: size and decoding (R35), freshness (R36), the request signature (R38), replay (R37), rate limits (R53), then the rules of the action. The signature comes before replay and rate limits so that nobody can use up a key’s daily allowance with requests they cannot sign.

  • R35. The body is at most 256 KB, decoded strictly: unknown members, duplicate members and trailing data are refused.
  • R36. issued_at is within ten minutes of the registry’s clock.
  • R37. The submission id is the hash of the canonical request object. A submission id already seen is refused as a replay.
  • R38. request_sig is public_key’s signature over the canonical request, and request.key_id is the id of public_key.
  • R39. entry_sig is the requester’s signature over the new ownership-log entry: seq one above the last, kind, the new key_id and public_key, publisher, and at equal to issued_at. It becomes that entry’s by_previous.
  • R39a. rotate-key moves one app to a new key: the request is signed by the current owner key, new_key_sig proves the new key is held, and the new key joins the publisher’s keys. A key that no longer owns any app is marked revoked in the publisher record.
  • R40. A publisher’s first request claims the name. It must carry the current terms version; without it the answer is terms_required with the terms address. A new terms version is asked for at the publisher’s next request.
  • R41. The answer is 202 with {"submission_id", "state", "code", "reason"}. The same answer is written to the submission document.

Rules for publish:

  • R42. The manifest must validate exactly as a node validates it at install, and its app.id, app.version and app.publisher must equal the request’s.
  • R43. The app id is unclaimed, or the signing key is its current owner key (R30).
  • R44. The publisher name is unclaimed, or the key is one of that publisher’s keys.
  • R45. The version is strictly greater than every published version of the app. The same version with the same manifest_sha256 is answered as already published (accepted, nothing changes); with different contents it is refused.
  • R46. Upgrading from the latest published version to this one must succeed under the node’s upgrade rules. The registry stores the plan and shows it in review.
  • R47. A registry may reserve publisher names and app-id prefixes for its operators; anyone else’s claim on one is refused. The central registry reserves the publisher cloudoak and app ids under com.example.. A registry run by the developer using it (a folder, the stand-in) reserves none.

Every submission ends in one state and one code:

State Codes
accepted accepted, already_published
in_review review_new_publisher, review_new_app, review_new_address, review_transfer
rejected bad_request, too_large, stale_request, replayed, rate_limited, daily_cap, bad_signature, terms_required, invalid_manifest, not_owner, publisher_taken, reserved_name, version_not_greater, version_conflict, upgrade_refused, not_found, domain_unverified, rejected_in_review
  • R48. Straight through: a new version of a listed app, signed by its current owner key, from a publisher in good standing, that adds no sign-in return address and no web origin.
  • R49. Waits for review: a publisher’s first submission, a new app id, a version that adds a return address or an origin, and a transfer.
  • R50. The owner key yanks and unyanks its own versions at once. A yanked version stays stored and verifiable; a node never installs it and warns its admin when it runs it, and never removes an app or its data by itself.
  • R51. A transfer completes only when the receiving key submits accept-transfer and the transfer has passed review. Until then the log is unchanged.
  • R52. verify-domain marks a publisher verified when https://<domain>/.well-known/cloudoak-publisher.json lists one of its key ids. The fetch is https only, never follows a redirect off the host, and never connects to a private, loopback or link-local address.
  • R53. Rate limits, counted per UTC day over signed submissions that are not replays: 20 submissions per key; 3 new publisher claims per client address; across everyone, 500 submissions and 50 new publisher claims. Past a per-key or per-address limit the answer is rate_limited, past a global one daily_cap, and nothing else is touched. Every signed submission counts against its key and the global total, whatever the answer. A submission counts as a new publisher claim only when it is accepted or put in review: a first publish sent back for the terms (terms_required), or refused, claims no name, and the same publish sent again with the terms accepted claims it once. The claim limits are checked before the submission is decided.

Review requests are signed by a review key and sent from the operator’s machine:

{"request": {"action": "approve", "submission_id": "…", "reason": "…",
"key_id": "<review key id>", "issued_at": "…", "nonce": "…"},
"request_sig": "…"}

Actions: approve, reject (a submission in review); yank, unyank, remove, relist (any app or version, with a reason); recover (adds a recover entry naming a new key, after an out-of-band check).

  • R54. Review requests follow R35 to R38, signed by a configured review key. Any other key is refused.

A node that refuses a document says why with one of these codes (and a sentence for people): bad_protocol, bad_base, redirect_off_host, untrusted_root, bad_key_id, key_dates, untrusted_signer, rollback, equivocation, stale_index, hash_mismatch, publisher_signature, not_current_owner, broken_chain, pin_not_in_log, recover_needs_approval, yanked, removed.

  • R55. keys.json verifies against a root key (R18), then every online key is checked for its validity dates (R19).
  • R56. catalog.json and each index.json verify against an online key (R19), with no rollback (R21, R24).
  • R57. For an install or upgrade: the version document’s hash matches the signed index (R26), then the canonical manifest’s hash, then the publisher’s signature (R27), then the ownership walk (R34). A version that is yanked (R50) or an app that is removed (R23) is not installed.
  • R58. A registry with no keys.json is an unsigned registry: it works with first-seen pinning of the publisher key, and the node labels it so wherever it is shown. A registry that has a keys.json that does not verify is refused, not treated as unsigned.
  • R59. POST {meta.ping} with an empty body answers {"ok": true, "at": "…"}. The registry keeps exactly one row for it, updated in place at most once a minute, and no record of who pinged. A node pings at most once per sync, best effort.

A private registry is the same documents in a folder (cloudoak app publish --out <folder>) or on any static host, read through a plain base. CLOUDOAK_REGISTRY_KEYS lists the root keys an operator trusts for such registries; without one, the registry is unsigned (R58).

Built from spec 2.1.0, SDK 2.0.0 and the node's guide at v0.6.0-alpha.1.