# RFC-1 — CivicIPFS Addon Specification **Status:** Draft **Version:** 0.3 **Changes from 0.1:** Canonical civic realm name established as `CIVICUS_IPFS`. Added `ConfigContract::REALM_CIVIC` constant. VersionGate extended to include realm check. Admin panel description updated. **Changes from 0.2 (this version):** Authentication contract in section 6.3 corrected and fully specified from source. Channel Ed25519 keypair (`channel_eprvkey`/`channel_epubkey`) confirmed as the signing key for addon requests. RSA-4096 primary key documented as separate. `CryptoContract` added to CoreBoundary. **Verified against:** Hubzilla 11.2.1 / Zot 6.0 **Author:** CivicIPFS Project **Repository:** TBD **RFC-2 (Orchestrator Specification):** TBD — separate document --- ## 1. Purpose and Scope This document specifies the CivicIPFS Hubzilla addon. It covers: - What the addon does inside Hubzilla - The CoreBoundary contracts — every point of contact with Hubzilla core, named and versioned - The Subscription API — the interface between the addon and an orchestrator service - The UI surface presented to the Hubzilla user - The addon file structure and namespace design - What is explicitly out of scope for this document This RFC does not specify the orchestrator service, the inter-orchestrator protocol, the challenge-proof mechanism, or the reputation propagation model. Those are specified in RFC-2. --- ## 2. Design Principles These principles are binding on all implementation decisions. When a decision conflicts with a principle, the principle wins and the decision is reconsidered. ### 2.1 Isolation from core The addon is 100% isolated from Hubzilla core at the code level. It communicates with core exclusively through the hook system documented in `include/plugin.php`. It does not call core functions directly except through named, documented, versioned contracts in the `CivicIpfs\CoreBoundary` namespace. ### 2.2 Adaptive boundary isolation Every dependency on Hubzilla core behaviour is: - Named as a constant or documented interface in `CivicIpfs\CoreBoundary` - Attributed to a specific source file and line number in core - Versioned against the Hubzilla release it was verified against - Checked at addon load time against the running core version, with a logged warning if they differ When Hubzilla core evolves, the starting point for any compatibility audit is `CivicIpfs\CoreBoundary`. Nothing else needs to be searched. ### 2.3 Readability and maintainability over cleverness Every class has one responsibility. Every method does one thing. Every dependency is injected, not reached for globally. No magic. No dynamic dispatch that cannot be followed by reading the code linearly. A developer who has never seen this codebase should be able to understand any single file without reading any other file first. ### 2.4 The orchestrator is external The addon does not contain IPFS logic. It does not contain pin management logic. It does not contain challenge-proof logic. It does not contain reputation logic. All of that lives in the orchestrator. The addon's job is to be the clean, well-documented bridge between Hubzilla identity and an orchestrator subscription. ### 2.5 No private content All content addressed through CivicIPFS is public. The addon provides no encryption layer, no key distribution, no access control on content. Content-addressed storage is inherently public — a CID resolves to the same content for any requester. This is a deliberate design boundary, not a deferred feature. ### 2.6 The evidentiary purpose is primary CivicIPFS exists to make public records permanently retrievable and verifiable. Every design decision that trades verifiability for convenience is wrong. Every design decision that trades durability for speed is wrong. The use cases are public health records, policy documents, civic archives, and scientific data — content whose integrity matters more than its delivery performance. --- ## 3. What the Addon Does The addon provides a Hubzilla channel with the ability to: 1. **Browse orchestrators** — view a list of available orchestrator subscriptions, with their advertised service levels, prices, pinning durations, and reputation scores as reported by the orchestrator 2. **Subscribe to an orchestrator** — configure the addon to use a specific orchestrator endpoint, authenticated by the channel's Zot6 identity 3. **Pin content** — submit a CID for pinning, selecting service level, price tier, and duration from the subscribed orchestrator's offer 4. **View pin state** — see the current status of all pinned CIDs: active, expired, challenged, confirmed 5. **View receipts** — retrieve signed pin receipts issued by the orchestrator as verifiable commitments 6. **View reputation data** — see the orchestrator's current reputation score as reported by the network 7. **Unpin content** — request removal of a pin commitment The addon does not: - Run an IPFS node - Manage pin storage directly - Issue or respond to challenges - Propagate reputation scores - Handle payment — payment is between the user and the orchestrator operator, outside this system - Store or serve content --- ## 4. Architecture ``` ┌─────────────────────────────────────────────────────┐ │ Hubzilla Core │ │ (identity, permissions, hooks, config, UI shell) │ └───────────────────┬─────────────────────────────────┘ │ hooks only │ CoreBoundary contracts ┌───────────────────▼─────────────────────────────────┐ │ CivicIPFS Addon │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ │ │ CoreBoundary│ │Subscription │ │ UI Layer │ │ │ │ namespace │ │ API Client │ │ (widgets) │ │ │ └─────────────┘ └──────┬───────┘ └────────────┘ │ └─────────────────────────┼───────────────────────────┘ │ Subscription API │ (HTTP/JSON, versioned) ┌─────────────────────────▼───────────────────────────┐ │ Orchestrator Service │ │ (specified in RFC-2) │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Kubo IPFS Node │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` The addon operator does not run an orchestrator. They point the addon at an orchestrator run by someone else (or themselves if they choose to run both). The orchestrator subscription is independent of the Hubzilla installation. --- ## 5. CoreBoundary Contracts These are every point of contact between the addon and Hubzilla core. No other contact exists. Each entry records the source file, the line number verified, and the Hubzilla version at time of verification. ### 5.1 Entry Points Hubzilla loads addons via `include/plugin.php`. The following bare global functions are the only global namespace entries the addon makes. Each delegates immediately to a namespaced class. ```php // addon/civicipfs/civicipfs.php function civicipfs_install() { CivicIpfs\Addon::install(); } function civicipfs_uninstall(){ CivicIpfs\Addon::uninstall(); } function civicipfs_load() { CivicIpfs\Addon::load(); } function civicipfs_unload() { CivicIpfs\Addon::unload(); } ``` **Verified:** `include/plugin.php` — `load_plugin()`, `install_plugin()`, `uninstall_plugin()`, `unload_plugin()` functions. Hubzilla 11.2.1. ### 5.2 Hook Contracts Defined in `CivicIpfs\CoreBoundary\HookContract`. ``` HOOK_ACTIVITY_RECEIVED = 'activity_received' Source: Zotlabs/Lib/Libzot.php line 1979 Data: ['item_id' => int, 'item' => array, 'sender' => array, 'channel' => array] Use: Detect when a channel receives a CID reference embedded in an activity, trigger local pin offer HOOK_CHANNEL_PROTOCOLS = 'channel_protocols' Source: Zotlabs/Lib/Libzot.php line 2929 Data: ['channel_id' => int, 'protocols' => array] Use: Advertise that a channel supports civicipfs protocol to peers during capability exchange HOOK_ZOTINFO = 'zotinfo' Source: Zotlabs/Lib/Libzot.php line 2999 Data: the zotinfo response array (mutable) Use: Inject civicipfs capability advertisement into the channel's zotinfo response HOOK_POST_LOCAL = 'post_local' Source: Zotlabs/Lib/Libzot.php line 1933 Data: item array (mutable) Use: Detect local posts containing CID references, offer to pin referenced content automatically HOOK_IDENTITY_EXPORT = 'identity_basic_export' Source: include/channel.php line 1164 Data: ['channel_id' => int, 'sections' => array, 'data' => array (mutable)] Use: Pack channel JSON store into export bundle by writing into $addon['data']['civicipfs'] HOOK_IMPORT_CHANNEL = 'import_channel' Source: Zotlabs/Module/Import.php line 523 Data: ['channel' => array, 'data' => array] Use: Unpack civicipfs JSON store from import bundle and write JSON files for the new channel instance ``` **Note:** There is no hook in `Libzot::import()` that fires for unknown Zot6 packet types. Unknown types are silently discarded after the type check at lines 1243 and 1323. Inter-orchestrator coordination therefore does not use Zot6 packet delivery — it uses the orchestrator-to-orchestrator protocol specified in RFC-2. This is a verified architectural boundary, not a deferred decision. ### 5.3 Config Contracts Defined in `CivicIpfs\CoreBoundary\ConfigContract`. ``` REALM_FAMILY = 'system' REALM_KEY = 'directory_realm' REALM_DEFAULT = 'RED_GLOBAL' Source: boot.php line 106 (DIRECTORY_REALM constant) boot.php lines 2441-2447 (get_directory_realm()) Use: Read at load time to verify realm membership. Addon emits a logged warning if value is 'RED_GLOBAL' and federation mode is requested. REALM_CIVIC = 'CIVICUS_IPFS' The canonical realm name for the CivicIPFS network. An operator sets system.directory_realm to this value to join the civic network. This constant is the single authoritative definition of the realm name in the addon codebase. It must not appear as a literal string anywhere else. REALM_TOKEN_FAMILY = 'system' REALM_TOKEN_KEY = 'realm_token' Source: Zotlabs/Lib/Libzotdir.php (sync_directories) Use: Read when verifying civic realm membership. Not set or distributed by this addon — set by the hub administrator out of band. ADDON_FAMILY = 'civicipfs' ADDON_ORCHESTRATOR = 'orchestrator_url' ADDON_MODE = 'mode' // 'local' | 'federated' ADDON_VERSION = 'contract_version' Use: Addon-specific configuration. Written by civicipfs admin panel, read at runtime. ``` ### 5.4 Version Gate Defined in `CivicIpfs\CoreBoundary\VersionGate`. At `civicipfs_load()`, the addon runs two checks. **Core version check.** It reads `STD_VERSION` from the running Hubzilla instance and compares it against `VERIFIED_CORE_VERSION = '11.2.1'`. If they differ: ``` [civicipfs] Core version mismatch. Verified against: 11.2.1 Running: {actual version} Review CivicIpfs\CoreBoundary contracts before assuming compatibility. ``` **Realm check.** It reads `get_directory_realm()` and compares it against `ConfigContract::REALM_CIVIC` (`'CIVICUS_IPFS'`). If the realm is `RED_GLOBAL` and federation mode is enabled: ``` [civicipfs] Realm mismatch. Federation requires realm: CIVICUS_IPFS Current realm: RED_GLOBAL Federation features are disabled until realm is changed. Local pinning remains active. ``` Both are warnings, not errors. The addon continues to load. Local pinning functions regardless of realm. Federation features are silently gated until the realm matches `ConfigContract::REALM_CIVIC`. The realm name `CIVICUS_IPFS` never appears as a literal string anywhere in the addon except in `ConfigContract`. **Verified:** `boot.php` — `STD_VERSION` constant. Hubzilla 11.2.1. ### 5.5 CryptoContract Defined in `CivicIpfs\CoreBoundary\CryptoContract`. Documents every cryptographic fact the addon depends on about Hubzilla's key and signing infrastructure. ``` CHANNEL_KEY_TYPE_PRIMARY = 'RSA-4096' Source: include/channel.php line 241 include/crypto.php new_keypair() Note: Used for Zot6 protocol signing via Libzot::sign(). Format: PEM. Signature: 'sha256.' . base64url($sig). NOT used for addon Subscription API requests. CHANNEL_KEY_TYPE_SECONDARY = 'Ed25519' Source: include/channel.php lines 243-245 Note: Generated via sodium_crypto_sign_keypair(). DB fields: channel_eprvkey (secret key, base64 no-padding), channel_epubkey (public key, base64 no-padding). Zotinfo field: ed25519_key (Multibase-encoded xchan_epubkey). Used for addon Subscription API request signing. SIGN_FUNCTION = 'sodium_crypto_sign_detached' Source: PHP sodium extension (core since PHP 7.2) Input: string $message, string $secretkey (raw bytes, 64 bytes) Output: raw signature bytes (64 bytes), base64_encode for header VERIFY_FUNCTION = 'golang.org/x/crypto/ed25519.Verify' Used by: orchestrator to verify incoming addon requests Input: publicKey (32 bytes), message, signature (64 bytes) ZOTINFO_PUBKEY_FIELD = 'ed25519_key' Source: Zotlabs/Lib/Libzot.php line 2906 Format: Multibase-encoded Ed25519 public key Endpoint: GET {hub_url}/.well-known/zot-info?channel_address={addr} SIGNING_ALGORITHM_DECLARED = 'rsa-sha256' Source: Zotlabs/Lib/Libzot.php line 2907 Note: This is the declared algorithm for Zot6 protocol signing. The addon uses Ed25519 (secondary key) for its own requests, which is separate from the Zot6 protocol signing algorithm. ``` --- ## 6. Subscription API The Subscription API is the interface between the addon and the orchestrator. It is the only channel through which the addon communicates with the outside world. ### 6.1 Principles - HTTP/JSON over HTTPS - All requests carry a Zot6 channel signature in the `X-CivicIPFS-Sig` header, allowing the orchestrator to verify the requesting identity without any separate authentication mechanism - The API is versioned — the URL path carries the version: `/api/v1/` - The addon sends the API version it expects in every request header: `X-CivicIPFS-Version: 1` - If the orchestrator returns a version mismatch error, the addon surfaces this to the user and logs it — it does not silently degrade ### 6.2 Endpoints (addon-facing) These are the calls the addon makes to the orchestrator. The orchestrator specification in RFC-2 defines the implementation side. ``` GET /api/v1/info Returns orchestrator identity, version, service offers, reputation summary, and current capacity. GET /api/v1/offers Returns the full list of service tiers with prices, pinning durations, and SLA commitments. POST /api/v1/pin Body: { cid, service_tier, duration_days } Returns: signed pin receipt GET /api/v1/pin/{cid} Returns current pin state for a CID: { cid, status, pinned_at, expires_at, receipt } DELETE /api/v1/pin/{cid} Request unpin. Returns acknowledgement. GET /api/v1/pins Returns all pins for the authenticated channel. GET /api/v1/reputation Returns the orchestrator's current reputation score and spot-check history as reported by the network. ``` ### 6.3 Authentication Every request from the addon carries: ``` X-CivicIPFS-Channel: {channel_hash} X-CivicIPFS-Sig: {Ed25519 signature of canonical request body} X-CivicIPFS-Version: 1 ``` **Key type — verified from source:** Hubzilla channels have two keypairs, both created at channel creation (`include/channel.php` lines 241–245): 1. **RSA-4096** (`channel_prvkey` / `channel_pubkey`) — the primary Zot6 identity key. Used by `Libzot::sign()` via `openssl_sign()` with SHA-256. Signature format: `'sha256.' . base64url_encode($sig)`. Advertised as `signing_algorithm: 'rsa-sha256'` in Zotinfo. 2. **Ed25519** (`channel_eprvkey` / `channel_epubkey`) — generated via `sodium_crypto_sign_keypair()`. Stored as base64, no padding, standard variant. Advertised in Zotinfo as `ed25519_key` (Multibase-encoded via `xchan_epubkey`). The addon signs requests using the channel's **Ed25519 private key**: ```php // CivicIpfs\Subscription\Request::sign() $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $prvkey = base64_decode($channel['channel_eprvkey']); $sig = sodium_crypto_sign_detached($body, $prvkey); $headers['X-CivicIPFS-Sig'] = base64_encode($sig); ``` Ed25519 is chosen over RSA because: - The orchestrator's own identity key is Ed25519 — one verification path for all signatures the orchestrator checks - `sodium_crypto_sign_detached()` is available in PHP 7.2+ as a core function — no library dependency - Signatures are 64 bytes, compact in HTTP headers The orchestrator retrieves the channel's `ed25519_key` from the Hubzilla Zotinfo endpoint (`/.well-known/zot-info`) at registration and caches it. Verification uses `golang.org/x/crypto/ed25519`. A channel that migrates to a new Hubzilla instance retains its orchestrator relationship automatically — `channel_hash`, `channel_eprvkey`, and `channel_epubkey` are all preserved through Hubzilla's nomadic migration (verified: they travel in the channel export via the `identity_basic_export` hook). --- ## 7. File Structure ``` addon/civicipfs/ │ ├── civicipfs.php ← entry point, 4 bare functions only │ ├── Addon.php ← CivicIpfs\Addon │ install/uninstall/load/unload logic │ ├── CoreBoundary/ │ ├── HookContract.php ← CivicIpfs\CoreBoundary\HookContract │ ├── ConfigContract.php ← CivicIpfs\CoreBoundary\ConfigContract │ └── VersionGate.php ← CivicIpfs\CoreBoundary\VersionGate │ ├── Subscription/ │ ├── Client.php ← CivicIpfs\Subscription\Client │ │ HTTP client for orchestrator API │ ├── Request.php ← CivicIpfs\Subscription\Request │ │ builds and signs API requests │ ├── Response.php ← CivicIpfs\Subscription\Response │ │ parses and validates responses │ └── OrchestratorOffer.php ← CivicIpfs\Subscription\OrchestratorOffer │ value object for a service offer │ ├── Pin/ │ ├── PinRequest.php ← CivicIpfs\Pin\PinRequest │ ├── PinReceipt.php ← CivicIpfs\Pin\PinReceipt │ └── PinState.php ← CivicIpfs\Pin\PinState │ ├── Hook/ │ ├── ActivityReceived.php ← CivicIpfs\Hook\ActivityReceived │ ├── ChannelProtocols.php ← CivicIpfs\Hook\ChannelProtocols │ ├── ZotInfo.php ← CivicIpfs\Hook\ZotInfo │ ├── PostLocal.php ← CivicIpfs\Hook\PostLocal │ ├── IdentityExport.php ← CivicIpfs\Hook\IdentityExport │ │ packs JSON store into export bundle │ └── ImportChannel.php ← CivicIpfs\Hook\ImportChannel │ unpacks JSON store from import bundle │ ├── Store/ │ ├── ChannelStore.php ← CivicIpfs\Store\ChannelStore │ │ read/write per-channel JSON files │ └── Schema.php ← CivicIpfs\Store\Schema │ JSON schema version constants │ and migration between schema versions │ ├── Ui/ │ ├── AdminPanel.php ← CivicIpfs\Ui\AdminPanel │ ├── ChannelPanel.php ← CivicIpfs\Ui\ChannelPanel │ └── OrchestratorBrowser.php ← CivicIpfs\Ui\OrchestratorBrowser │ └── views/ ├── admin.tpl ├── channel.tpl └── browser.tpl ``` --- ## 8. Local State Store The addon does not create tables in Hubzilla's MariaDB database. All addon state is stored in per-channel JSON files in a dedicated directory. ``` data/civicipfs/ {channel_hash}.subscriptions.json {channel_hash}.pins.json {channel_hash}.version_log.json ``` ### 8.1 Subscriptions file — `{channel_hash}.subscriptions.json` ```json { "schema": 1, "channel_hash": "...", "subscriptions": [ { "orchestrator_url": "https://...", "subscribed_at": 1700000000, "last_seen_at": 1700000000, "reputation_score": 0.97, "active": true } ] } ``` ### 8.2 Pins file — `{channel_hash}.pins.json` ```json { "schema": 1, "channel_hash": "...", "pins": [ { "cid": "bafybeig...", "orchestrator_url": "https://...", "service_tier": "standard", "pinned_at": 1700000000, "expires_at": 1731536000, "status": "active", "receipt": { } } ] } ``` Status values: `active` | `expired` | `challenged` | `confirmed` | `failed` The `receipt` field stores the signed receipt JSON received from the orchestrator verbatim. ### 8.3 Authoritative record The orchestrator holds the authoritative record. The local JSON store is a cache and display layer. If lost during nomadic migration, the addon rebuilds it from the orchestrator using the channel's Zot6 identity. ### 8.4 Nomadic migration The addon registers against two hooks to include its JSON state in the channel export/import bundle. Both are verified from source. **Export hook — verified: `include/channel.php` line 1164** ``` call_hooks('identity_basic_export', $addon) Data (by reference): 'channel_id' => int 'sections' => array — section names being exported 'data' => array — mutable, addon writes into $addon['data']['civicipfs'] ``` The addon's `CivicIpfs\Hook\IdentityExport` handler adds a `civicipfs` key to `$addon['data']` containing the serialised contents of both JSON files for the channel being exported. **Import hook — verified: `Zotlabs/Module/Import.php` line 523** ``` call_hooks('import_channel', $addon) Data (by reference): 'channel' => array — the channel record just imported 'data' => array — the full import data including $data['civicipfs'] ``` The addon's `CivicIpfs\Hook\ImportChannel` handler reads `$addon['data']['civicipfs']` and writes the JSON files to the new instance's data directory under the channel's hash. These two hooks are symmetrical and purpose-built for exactly this use case. No core modification is required. The nomadic migration gap is fully closed. --- ## 9. UI Surface ### 9.1 Admin panel Accessible to hub administrators. Configures: - Default orchestrator URL for this hub (users may override per-channel) - Federation mode: `local` (addon active, no civic realm features) or `federated` (requires realm `CIVICUS_IPFS` — gated by VersionGate realm check) - Version gate warning display ### 9.2 Channel panel Accessible to the channel owner. Shows: - Current orchestrator subscription and its reputation score - List of active pins with status, expiry, and receipt availability - Controls to pin new content (CID input, tier/price/duration selection) - Controls to unpin ### 9.3 Orchestrator browser Lists orchestrators that have been manually configured by the hub administrator or the channel owner. There is no automatic discovery mechanism and there will never be one. Automatic discovery would require a registry under someone's control, reintroducing a central point of failure and a governance dependency that the entire architecture is designed to avoid. The relationship between a Hubzilla addon user and an orchestrator operator is a deliberate, human choice — equivalent to the choice between a static IP address and DHCP. The addon provides the configuration field. The operator fills it in. That is the complete discovery mechanism, by permanent design. The admin panel allows the hub administrator to pre-configure a default orchestrator URL for all channels on the hub. Individual channel owners may override this with their own orchestrator subscription. --- ## 10. Deferred Items The following are explicitly deferred. They are recorded here so they are not forgotten and so their absence from this RFC is a documented decision, not an oversight. **D1 — Private content.** No encryption layer, no access control on content. All content addressed through CivicIPFS is public. This is a permanent design boundary for version 1, not a deferred feature. **D2 — Directory bootstrapping.** How the civic realm directory is operated. Deferred until the network has enough nodes to warrant it. Orchestrator discovery is manual by permanent design and is not part of this deferral. **D3 — Attestation and timestamping.** Anchoring CIDs to an external manipulation-resistant clock via Duniter or alternatives. Deferred — this requires a separate architectural decision and will be specified in a dedicated RFC. **D5 — Payment integration.** Payment is between the user and the orchestrator operator, outside this system entirely. Not deferred — permanently out of scope. --- ## 11. Open Questions All architectural questions are resolved. The following note remains for completeness. **Q2 — Nomadic identity and pin state. RESOLVED.** See section 8.4. Both the export hook (`identity_basic_export`, `include/channel.php` line 1164) and the import hook (`import_channel`, `Zotlabs/Module/Import.php` line 523) are verified from source. Migration is fully handled within the addon. --- ## 12. What RFC-2 Specifies For completeness, the following are out of scope for RFC-1 and will be specified in RFC-2: - The orchestrator service architecture - The Kubo IPFS node integration - The inter-orchestrator protocol - The `ipfs_pin`, `ipfs_announce`, `ipfs_query`, `ipfs_challenge`, `ipfs_proof`, `ipfs_reputation` message types and their transport - The challenge-proof mechanism - The reputation propagation model - The subscription management API (orchestrator side) - The service offer advertisement mechanism --- *End of RFC-1 draft 0.1*