diff --git a/hubzilla/addon/g1wallet/g1wallet.php b/hubzilla/addon/g1wallet/g1wallet.php index f8f6485..e793b9d 100644 --- a/hubzilla/addon/g1wallet/g1wallet.php +++ b/hubzilla/addon/g1wallet/g1wallet.php @@ -2,8 +2,8 @@ /** * Name: Ğ1 Wallet - * Description: Self-sovereign Ğ1 wallet for SASE-verified participants. Key derivation and signing in the browser. The platform never touches your keys. - * Version: 0.2.0 + * Description: Ğ1 address registration and balance display for SASE-verified participants. + * Version: 0.3.0 * MinVersion: 11.0 * MaxVersion: 12.0 */ @@ -26,7 +26,6 @@ function g1wallet_unload() { } function g1wallet_load_pdl(&$b) { - // Loads the g1wallet PDL layout for g1wallet module pages. if (!is_array($b) || empty($b['module']) || $b['module'] !== 'g1wallet') { return; } @@ -41,12 +40,10 @@ function g1wallet_load_pdl(&$b) { // ----------------------------------------------------------------------------- function g1wallet_h($value) { - // HTML-escapes a value for safe output. return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } function g1wallet_load_config() { - // Loads config.json from the addon directory. Returns array or empty array on failure. $raw = @file_get_contents('addon/g1wallet/config.json'); if ($raw === false) return []; $cfg = json_decode($raw, true); @@ -59,8 +56,7 @@ function g1wallet_load_config() { function g1wallet_access_state() { // Returns operator, participant, or public. - // Does not call local_channel() for group checks — safe for guest tokens. - // Operator: local channel whose channel_id matches local_channel(). + // Direct pggrp_member query — safe for guest tokens. if (local_channel()) { $channel = App::get_channel(); if (local_channel() === intval($channel['channel_id'])) { @@ -71,7 +67,6 @@ function g1wallet_access_state() { $observer = get_observer_hash(); if (!$observer) return 'public'; - // Load all registered associations from vs01 config and check group membership. $raw = @file_get_contents('addon/vs01/config.json'); if ($raw === false) return 'public'; $cfg = json_decode($raw, true); @@ -80,7 +75,6 @@ function g1wallet_access_state() { $associations = $cfg['associations'] ?? []; if (empty($associations)) return 'public'; - // Direct pggrp_member query — works for guest tokens. foreach ($associations as $slug => $assoc) { $groups = $assoc['groups'] ?? []; foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) { @@ -106,13 +100,6 @@ function g1wallet_content() { if (function_exists('head_add_css')) { head_add_css('/addon/g1wallet/view/css/g1wallet.css'); } - if (function_exists('head_add_js')) { - // Load order: tweetnacl → bip39 → g1wallet.js - head_add_js('/addon/g1wallet/vendor/tweetnacl-1.0.3.min.js'); - head_add_js('/addon/g1wallet/vendor/bip39-3.1.0.min.js'); - head_add_js('/addon/g1wallet/view/js/g1wallet.js'); - // Note: vendor/scrypt-js-3.0.1.min.js is NOT loaded (obsolete Cesium1 algorithm). - } $access = g1wallet_access_state(); $sub_route = strtolower(argv(1) ?? ''); @@ -121,37 +108,16 @@ function g1wallet_content() { 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 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); + // POST: store g1 address + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $sub_route === 'address') { + if (!g1wallet_verify_csrf()) { + return g1wallet_render_error('Invalid form token. Please reload and try again.'); + } + return g1wallet_handle_address_post($access); } + + // Default: wallet landing page + return g1wallet_render_landing($access); } // ----------------------------------------------------------------------------- @@ -159,7 +125,6 @@ function g1wallet_content() { // ----------------------------------------------------------------------------- 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)); } @@ -168,7 +133,6 @@ function g1wallet_csrf_token() { } 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 index 8786e97..251fa6f 100644 --- a/hubzilla/addon/g1wallet/g1wallet_renderer.php +++ b/hubzilla/addon/g1wallet/g1wallet_renderer.php @@ -2,8 +2,6 @@ /** * g1wallet_renderer.php — All HTML rendering for g1wallet. - * Knows nothing about network calls or POST handling. - * Knows nothing about crypto — that lives entirely in g1wallet.js. */ // ----------------------------------------------------------------------------- @@ -12,142 +10,142 @@ function g1wallet_render_access_wall() { $directory_url = g1wallet_h(z_root() . '/channel/theron'); + $hostname = g1wallet_h(App::get_hostname()); return '
'; } // ----------------------------------------------------------------------------- -// WALLET LANDING +// LANDING PAGE // ----------------------------------------------------------------------------- function g1wallet_render_landing($access) { - // Wallet landing: shows unlock form or unlocked interface depending on JS session state. - // JS swaps to the unlocked view on successful derivation. + $channel_id = local_channel(); + $stored_addr = $channel_id ? get_pconfig($channel_id, 'g1wallet', 'g1_address') : ''; + $balance = ''; + + // If address is stored, fetch balance via cry01 chain query. + if ($stored_addr) { + $balance = g1wallet_fetch_balance($stored_addr); + } $out = '
'; - $out .= '
'; $out .= '

Ğ1 Wallet

'; - $out .= '

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

'; + $out .= '

Register your Ğ1 address to display your balance and participate in cost-bearing civic actions.

'; + + // Optional notice + $out .= '
'; + $out .= 'Optional. The Ğ1 Wallet is not required for browsing or submitting diagnostic records. '; + $out .= 'However, future actions such as Scenario submission will require a small Ğ1 payment. '; + $out .= 'The transaction record serves as proof of participation and is included in the attestation record.'; $out .= '
'; - // Optional participation notice. - $out .= '

'; - $out .= 'The Ğ1 Wallet is optional. '; - $out .= 'Ğ1 is a libre currency independent of fiat, used for valuing and bartering surplus capacity among private individuals. '; - $out .= 'The Civic Infrastructure implements it for participants who choose to engage in that economy. '; - $out .= 'It is not required for participation in the diagnostic record system. '; - $out .= 'Future addons such as Barter will require an active wallet — this page is where you manage it.'; - $out .= '

'; + // Current address + balance + if ($stored_addr) { + $out .= '
'; + $out .= '
Registered Address
'; + $out .= '

' . g1wallet_h($stored_addr) . '

'; - // 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. - // The mnemonic never leaves the browser. - // - // Derivation (Duniter v2 / Substrate / Ğecko standard): - // 12-word BIP39 mnemonic - // → mnemonicToEntropy() → 16 bytes of raw entropy - // → use entropy directly as 32-byte Ed25519 seed (zero-padded right) - // → derive Ed25519 keypair (SubtleCrypto, non-extractable private key) - // → SS58-encode public key with Ğ1 network prefix → g1... address - - $out = '
'; - $out .= '

Unlock Your Wallet

'; - $out .= '

Enter your 12-word Ğ1 mnemonic phrase. It is used only in your browser to derive your keypair. It is never sent to the server.

'; - - $out .= '
'; - $out .= ''; - $out .= ''; - $out .= '
Your 12-word Ğ1 mnemonic — the recovery phrase generated when your Ğ1 account was created. Words are separated by single spaces, lowercase, English wordlist.
'; - $out .= '
'; - - $out .= ''; - - $out .= '
'; - $out .= ''; - $out .= ''; - $out .= '
'; - - $out .= '
'; - return $out; -} - -// ----------------------------------------------------------------------------- -// UNLOCKED INTERFACE -// ----------------------------------------------------------------------------- - -function g1wallet_render_unlocked_placeholder($access) { - // Unlocked wallet interface. - // Populated by g1wallet.js after key derivation. - - $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.

'; + if ($balance !== '') { + $out .= '

Balance: ' . g1wallet_h($balance) . ' Ğ1

'; + } else { + $out .= '

Balance unavailable.

'; + } $out .= '
'; } + // Address registration form + $out .= '
'; + $out .= '
' . ($stored_addr ? 'Update Address' : 'Register Your Ğ1 Address') . '
'; + $out .= '

Paste your Ğ1 address (starts with g1, 46–47 characters). '; + $out .= 'You can find it in the Ğecko app or any Ğ1 wallet application.

'; + + $out .= '
'; + $out .= g1wallet_csrf_token(); + $out .= '
'; + $out .= ''; + $out .= '
' . g1wallet_h($message) . '
'; diff --git a/hubzilla/addon/g1wallet/g1wallet_spool.php b/hubzilla/addon/g1wallet/g1wallet_spool.php index e03dcda..5993cc5 100644 --- a/hubzilla/addon/g1wallet/g1wallet_spool.php +++ b/hubzilla/addon/g1wallet/g1wallet_spool.php @@ -2,151 +2,57 @@ /** * g1wallet_spool.php — POST handlers for g1wallet. - * pubkey store: receives public key (SS58 g1... address) after unlock, stores in channel settings. - * broadcast relay: receives signed Duniter transaction, relays to orchestrator. - * - * The private key never reaches this file. Ever. + * Currently: address registration only. + * Future: transaction broadcast (when scn01 payment is wired). */ -function g1wallet_handle_pubkey_post($access) { - // Stores the participant's Ğ1 public key (SS58 address) in their Hubzilla - // channel settings. Called once after each wallet unlock (server deduplicates - // by checking the existing value before writing). - // The public key is the only wallet-related thing the server ever stores. +function g1wallet_handle_address_post($access) { + $address = trim($_POST['g1_address'] ?? ''); - $pubkey = trim($_POST['g1_pubkey'] ?? ''); - - if (!$pubkey) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Public key is required.']); - killme(); + if (!$address) { + return g1wallet_render_landing_with_error($access, 'Ğ1 address is required.'); } - // Ğ1 SS58 addresses: base58-encoded, 36 bytes decoded, begin with "g1". - // Encoded length is 46–47 characters. Reject anything outside that range. - $len = strlen($pubkey); + // Ğ1 SS58 addresses: begin with "g1", 46–47 characters, base58 charset. + $len = strlen($address); if ($len < 46 || $len > 48) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Invalid public key format.']); - killme(); + return g1wallet_render_landing_with_error($access, + 'Invalid address length (' . $len . ' characters). A Ğ1 address is 46–47 characters long.'); } - // Must begin with "g1" (SS58 with Ğ1 network prefix). - if (strncmp($pubkey, 'g1', 2) !== 0) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Invalid public key prefix.']); - killme(); + if (strncmp($address, 'g1', 2) !== 0) { + return g1wallet_render_landing_with_error($access, + 'Invalid address: must begin with "g1".'); } - // Only base58 characters (Bitcoin alphabet, no 0/O/I/l). - if (!preg_match('/^[1-9A-HJ-NP-Za-km-z]+$/', $pubkey)) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Invalid public key characters.']); - killme(); + if (!preg_match('/^[1-9A-HJ-NP-Za-km-z]+$/', $address)) { + return g1wallet_render_landing_with_error($access, + 'Invalid address: contains characters not valid in a Ğ1 address.'); } - // Determine channel_id to write against. - // Operators use their own channel_id. Participants also use local_channel(). - // We do not write other channels' keys — only the caller's own. $channel_id = local_channel(); if (!$channel_id) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Not authenticated.']); - killme(); + return g1wallet_render_landing_with_error($access, 'Not authenticated.'); } - // Deduplicate: if the stored key matches, return ok without writing. - $existing = get_pconfig($channel_id, 'g1wallet', 'g1_pubkey'); - if ($existing === $pubkey) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'ok', 'note' => 'Key unchanged.']); - killme(); - } + set_pconfig($channel_id, 'g1wallet', 'g1_address', $address); - // Store the public key. - set_pconfig($channel_id, 'g1wallet', 'g1_pubkey', $pubkey); - - header('Content-Type: application/json'); - echo json_encode(['status' => 'ok']); - killme(); + // Render landing with success notice. + return g1wallet_render_landing_with_success($access, + 'Ğ1 address saved. Your balance will appear below.'); } -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. +// ----------------------------------------------------------------------------- +// LANDING VARIANTS WITH NOTICE +// These wrap g1wallet_render_landing() with a prepended notice. +// ----------------------------------------------------------------------------- - $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(); - } - - // Validate doc_type is a known type. - $allowed_types = ['transfer', 'certification']; - if (!in_array($doc_type, $allowed_types, true)) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Unknown doc_type.']); - killme(); - } - - // Load config for orchestrator endpoint. - $config = g1wallet_load_config(); - $orchestrator_url = rtrim($config['orchestrator_url'] ?? '', '/'); - $node_token = $config['node_token'] ?? ''; - - if (!$orchestrator_url || !$node_token) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Orchestrator not configured.']); - killme(); - } - - // Relay to orchestrator POST /g1wallet/broadcast. - $payload = json_encode([ - 'signed_doc' => $signed_doc, - 'doc_type' => $doc_type, - ]); - - $ch = curl_init($orchestrator_url . '/g1wallet/broadcast'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-Node-Token: ' . $node_token, - ]); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - - $raw = curl_exec($ch); - $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $err = curl_error($ch); - curl_close($ch); - - if ($err) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Orchestrator unreachable.']); - killme(); - } - - if ($http !== 200) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Orchestrator returned HTTP ' . intval($http) . '.']); - killme(); - } - - $result = json_decode($raw, true); - if (json_last_error() !== JSON_ERROR_NONE || !isset($result['status'])) { - header('Content-Type: application/json'); - echo json_encode(['status' => 'error', 'message' => 'Invalid orchestrator response.']); - killme(); - } - - header('Content-Type: application/json'); - echo json_encode($result); - killme(); +function g1wallet_render_landing_with_error($access, $message) { + $notice = '
' . g1wallet_h($message) . '
'; + return $notice . g1wallet_render_landing($access); +} + +function g1wallet_render_landing_with_success($access, $message) { + $notice = '
' . g1wallet_h($message) . '
'; + return $notice . g1wallet_render_landing($access); }