diff --git a/hubzilla/addon/g1wallet/README.md b/hubzilla/addon/g1wallet/README.md new file mode 100644 index 0000000..9441da9 --- /dev/null +++ b/hubzilla/addon/g1wallet/README.md @@ -0,0 +1,45 @@ +# g1wallet — Self-Sovereign Ğ1 Wallet + +**Version:** 0.1.0 (skeleton) +**Spec:** `G1WALLET-SPEC.md` in the repo root + +--- + +## What This Is + +A self-sovereign Ğ1 wallet embedded in the Civic Infrastructure. Every SASE-verified +participant — including the operator — holds their own Ğ1 identity under their own +credentials, in their own browser, under their own control. The platform never touches +the keys. + +## What Is Implemented at This Tag + +- Addon registration, routing, access state +- Wallet unlock form (UI only — no crypto yet) +- Unlocked interface placeholder +- Compact sidebar widget (locked/unlocked status) +- CivicNav registry entry + +## What Is Not Yet Implemented + +- scrypt key derivation (vendor/scrypt-js-3.0.1.min.js is present but not wired) +- Ed25519 signing via WebCrypto +- Wallet session state (JS) +- Pubkey POST to /g1wallet/pubkey +- Balance fetch from orchestrator +- Broadcast relay to Duniter network + +## Build Sequence (from G1WALLET-SPEC.md) + +1. `view/js/g1wallet.js` — key derivation, signing, session management, event API +2. `vendor/scrypt-js-3.0.1.min.js` — already present, pinned +3. `g1wallet.php` — entry point (done) +4. `g1wallet_renderer.php` — unlock form, unlocked interface (skeleton done) +5. `g1wallet_spool.php` — pubkey store, broadcast relay (stubs done) +6. Orchestrator additions — broadcast and balance endpoints in main.py +7. Widget, PDL, CSS (done) +8. Integration test against Duniter standard key derivation + +## Runtime Config + +Copy `config.json.template` to `config.json` on the host. Do not commit `config.json`. diff --git a/hubzilla/addon/g1wallet/g1wallet.php b/hubzilla/addon/g1wallet/g1wallet.php new file mode 100644 index 0000000..df4fcb2 --- /dev/null +++ b/hubzilla/addon/g1wallet/g1wallet.php @@ -0,0 +1,171 @@ + $assoc) { + $groups = $assoc['groups'] ?? []; + foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) { + $gid = intval($groups[$group_key] ?? 0); + if ($gid) { + $r = q("SELECT xchan FROM pgrp_member WHERE gid = %d AND xchan = '%s' LIMIT 1", + intval($gid), + dbesc($observer) + ); + if ($r) return 'participant'; + } + } + } + + return 'public'; +} + +// ---------------------------------------------------------------------------- +// CONTENT ROUTER +// ---------------------------------------------------------------------------- + +function g1wallet_content() { + if (function_exists('head_add_css')) { + head_add_css('/addon/g1wallet/view/css/g1wallet.css'); + } + if (function_exists('head_add_js')) { + head_add_js('/addon/g1wallet/vendor/scrypt-js-3.0.1.min.js'); + head_add_js('/addon/g1wallet/view/js/g1wallet.js'); + } + + $access = g1wallet_access_state(); + $sub_route = strtolower(argv(1) ?? ''); + + if ($access === 'public') { + return g1wallet_render_access_wall(); + } + + switch ($sub_route) { + + case 'balance': + // GET: return cached balance for the current session pubkey. + // Placeholder — orchestrator query not yet implemented. + return g1wallet_render_error('Balance fetch not yet implemented.'); + + case 'broadcast': + // POST: relay signed Duniter transaction to orchestrator. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + return g1wallet_render_error('POST required.'); + } + if (!g1wallet_verify_csrf()) { + return g1wallet_render_error('Invalid form token. Please reload and try again.'); + } + return g1wallet_handle_broadcast_post(); + + case 'pubkey': + // POST: store participant's public key in channel settings after unlock. + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + return g1wallet_render_error('POST required.'); + } + if (!g1wallet_verify_csrf()) { + return g1wallet_render_error('Invalid form token. Please reload and try again.'); + } + return g1wallet_handle_pubkey_post($access); + + default: + // Wallet landing: unlock form or unlocked interface. + return g1wallet_render_landing($access); + } +} + +// ---------------------------------------------------------------------------- +// CSRF +// ---------------------------------------------------------------------------- + +function g1wallet_csrf_token() { + // Generates and stores a CSRF token for the current session. + if (empty($_SESSION['g1wallet_csrf'])) { + $_SESSION['g1wallet_csrf'] = bin2hex(random_bytes(16)); + } + return ''; +} + +function g1wallet_verify_csrf() { + // Returns true if the CSRF token in POST matches the session token. + return isset($_POST['g1wallet_csrf'], $_SESSION['g1wallet_csrf']) + && hash_equals($_SESSION['g1wallet_csrf'], $_POST['g1wallet_csrf']); +} diff --git a/hubzilla/addon/g1wallet/g1wallet_renderer.php b/hubzilla/addon/g1wallet/g1wallet_renderer.php new file mode 100644 index 0000000..874ef9f --- /dev/null +++ b/hubzilla/addon/g1wallet/g1wallet_renderer.php @@ -0,0 +1,158 @@ + + + + '; +} + +// ---------------------------------------------------------------------------- +// WALLET LANDING +// ---------------------------------------------------------------------------- + +function g1wallet_render_landing($access) { + // Wallet landing: shows unlock form or unlocked interface depending on JS session state. + // At skeleton stage, always shows the unlock form. + // Once g1wallet.js is wired, the JS will swap to the unlocked view on successful derivation. + + $out = '
'; + $out .= '
'; + $out .= '

Ğ1 Wallet

'; + $out .= '

Your self-sovereign Ğ1 identity. Keys are derived in your browser and never leave your device.

'; + $out .= '
'; + + // Locked view — shown by default. JS hides this and shows unlocked-view on successful derivation. + $out .= '
'; + $out .= g1wallet_render_unlock_form(); + $out .= '
'; + + // Unlocked view — hidden by default. JS populates and shows this after derivation. + $out .= ''; + + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// UNLOCK FORM +// ---------------------------------------------------------------------------- + +function g1wallet_render_unlock_form() { + // Renders the wallet unlock form. + // The form is handled entirely by g1wallet.js — it does NOT POST to the server. + // Pseudo and password never leave the browser. + + $out = '
'; + $out .= '

Unlock Your Wallet

'; + $out .= '

Enter your Ğ1 credentials. These are used only in your browser to derive your keypair. They are never sent to the server.

'; + + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
Your Ğ1 pseudo — the same one you use in Cesium or Ğecko.
'; + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
Your Ğ1 password. Never sent to the server.
'; + $out .= '
'; + + $out .= ''; + + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + + $out .= '
'; + $out .= '

'; + $out .= 'Don\'t have a Ğ1 account? You can create one using '; + $out .= 'Cesium or '; + $out .= 'Ğecko.'; + $out .= '

'; + $out .= '
'; + + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// UNLOCKED INTERFACE (PLACEHOLDER) +// ---------------------------------------------------------------------------- + +function g1wallet_render_unlocked_placeholder($access) { + // Placeholder for the unlocked wallet interface. + // Populated by g1wallet.js once key derivation is implemented. + + $pubkey_url = z_root() . '/g1wallet/pubkey'; + + $out = '
'; + $out .= '
'; + $out .= 'Wallet unlocked.'; + $out .= ''; + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= '

'; + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= '

Not yet loaded.

'; + $out .= ''; + $out .= '
'; + + // Hidden form to store pubkey in channel settings — posted once after first unlock. + // g1wallet.js posts to this via fetch() after derivation, not via form submit. + $out .= ''; + + if ($access === 'operator') { + $out .= '
'; + $out .= '

You are the operator. Your Ğ1 public key is stored in your channel settings, not in config.

'; + $out .= '
'; + } + + $out .= '
'; + return $out; +} + +// ---------------------------------------------------------------------------- +// ERROR +// ---------------------------------------------------------------------------- + +function g1wallet_render_error($message) { + // Shows a plain-language error. Never shows a blank page or stack trace. + return '
' + . g1wallet_h($message) + . '
'; +} diff --git a/hubzilla/addon/g1wallet/g1wallet_spool.php b/hubzilla/addon/g1wallet/g1wallet_spool.php new file mode 100644 index 0000000..860e70f --- /dev/null +++ b/hubzilla/addon/g1wallet/g1wallet_spool.php @@ -0,0 +1,63 @@ + 64) { + return g1wallet_render_error('Invalid public key format.'); + } + + // TODO: store $pubkey in Hubzilla channel settings using set_pconfig() or equivalent. + // Placeholder: log and return success shell. + // set_pconfig(local_channel(), 'g1wallet', 'g1_pubkey', $pubkey); + + // Return JSON for fetch() caller in g1wallet.js. + header('Content-Type: application/json'); + echo json_encode(['status' => 'ok', 'note' => 'Pubkey storage not yet implemented.']); + killme(); +} + +function g1wallet_handle_broadcast_post() { + // Receives a signed Duniter transaction document (base64-encoded) from the browser. + // Validates the node token, relays to the orchestrator, returns the transaction hash. + // + // The browser signs the document with the participant's private key (WebCrypto). + // Only the signed bytes arrive here — never the private key. + + $signed_doc = trim($_POST['signed_doc'] ?? ''); + $doc_type = trim($_POST['doc_type'] ?? ''); // e.g. 'transfer', 'certification' + + if (!$signed_doc || !$doc_type) { + header('Content-Type: application/json'); + echo json_encode(['status' => 'error', 'message' => 'signed_doc and doc_type are required.']); + killme(); + } + + // TODO: load config, relay to orchestrator POST /g1wallet/broadcast. + // $config = g1wallet_load_config(); + // $orchestrator_url = $config['orchestrator_url'] ?? ''; + // $node_token = $config['node_token'] ?? ''; + // ... HTTP relay to orchestrator ... + + header('Content-Type: application/json'); + echo json_encode(['status' => 'ok', 'note' => 'Broadcast relay not yet implemented.']); + killme(); +}