This commit is contained in:
2026-06-14 07:45:02 -04:00
parent e3128639df
commit b08450904d
3 changed files with 150 additions and 282 deletions

View File

@@ -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']);
}

View File

@@ -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 '
<div class="g1wallet-content">
<div class="alert alert-info" role="alert">
<strong>SASE verification required to access the Ğ1 Wallet.</strong>
This wallet is available to verified HOA participants only.
To participate, you must complete the SASE process.
Visit <a href="' . $directory_url . '">' . g1wallet_h(App::get_hostname()) . '</a> to begin.
This feature is available to verified HOA participants only.
Visit <a href="' . $directory_url . '">' . $hostname . '</a> to begin the verification process.
</div>
</div>
';
}
// -----------------------------------------------------------------------------
// 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 = '<div class="g1wallet-content">';
$out .= '<div class="g1wallet-header mb-3">';
$out .= '<h2>Ğ1 Wallet</h2>';
$out .= '<p class="text-muted">Your self-sovereign Ğ1 identity. Keys are derived in your browser and never leave your device.</p>';
$out .= '<p class="text-muted">Register your Ğ1 address to display your balance and participate in cost-bearing civic actions.</p>';
// Optional notice
$out .= '<div class="alert alert-light border small mb-3">';
$out .= '<strong>Optional.</strong> 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 .= '</div>';
// Optional participation notice.
$out .= '<p class="text-muted small g1wallet-optional-notice">';
$out .= 'The Ğ1 Wallet is <strong>optional</strong>. ';
$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 .= '</p>';
// Current address + balance
if ($stored_addr) {
$out .= '<div class="g1wallet-address-display mb-4">';
$out .= '<h5>Registered Address</h5>';
$out .= '<p class="font-monospace small text-break">' . g1wallet_h($stored_addr) . '</p>';
// Locked view — shown by default. JS hides this and shows unlocked-view on successful derivation.
$out .= '<div id="g1wallet-locked-view">';
$out .= g1wallet_render_unlock_form();
$out .= '</div>';
// Unlocked view — hidden by default. JS populates and shows this after derivation.
$out .= '<div id="g1wallet-unlocked-view" style="display:none;">';
$out .= g1wallet_render_unlocked_placeholder($access);
$out .= '</div>';
$out .= '</div>';
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 = '<div class="g1wallet-unlock-form">';
$out .= '<h4>Unlock Your Wallet</h4>';
$out .= '<p class="text-muted small">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.</p>';
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="g1wallet-mnemonic">Mnemonic Phrase <span class="text-danger">*</span></label>';
$out .= '<textarea class="form-control font-monospace" id="g1wallet-mnemonic" name="g1wallet_mnemonic"
rows="2" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
placeholder="word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"></textarea>';
$out .= '<div class="form-text">Your 12-word Ğ1 mnemonic — the recovery phrase generated when your Ğ1 account was created. Words are separated by single spaces, lowercase, English wordlist.</div>';
$out .= '</div>';
$out .= '<div id="g1wallet-unlock-error" class="alert alert-danger" style="display:none;" role="alert"></div>';
$out .= '<div class="mt-3">';
$out .= '<button type="button" id="g1wallet-unlock-btn" class="btn btn-primary">Unlock Wallet</button>';
$out .= '<span id="g1wallet-unlock-spinner" class="ms-2 text-muted small" style="display:none;">Deriving keypair…</span>';
$out .= '</div>';
$out .= '</div>';
return $out;
}
// -----------------------------------------------------------------------------
// UNLOCKED INTERFACE
// -----------------------------------------------------------------------------
function g1wallet_render_unlocked_placeholder($access) {
// Unlocked wallet interface.
// Populated by g1wallet.js after key derivation.
$out = '<div class="g1wallet-unlocked">';
$out .= '<div class="alert alert-success d-flex justify-content-between align-items-center">';
$out .= '<span><strong>Wallet unlocked.</strong></span>';
$out .= '<button type="button" id="g1wallet-lock-btn" class="btn btn-sm btn-outline-secondary">Lock</button>';
$out .= '</div>';
$out .= '<div class="mb-3">';
$out .= '<h5 class="g1wallet-section-label">Public Key (Ğ1 Address)</h5>';
$out .= '<p id="g1wallet-pubkey-display" class="font-monospace text-muted small">—</p>';
$out .= '</div>';
$out .= '<div class="mb-3">';
$out .= '<h5 class="g1wallet-section-label">Ğ1 Balance</h5>';
$out .= '<p id="g1wallet-balance-display" class="text-muted fst-italic">Not yet loaded.</p>';
$out .= '<button type="button" id="g1wallet-refresh-balance-btn" class="btn btn-sm btn-outline-primary">Refresh Balance</button>';
$out .= '</div>';
// 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 .= '<form id="g1wallet-pubkey-form" style="display:none;">';
$out .= g1wallet_csrf_token();
$out .= '<input type="hidden" id="g1wallet-pubkey-input" name="g1_pubkey" value="">';
$out .= '</form>';
if ($access === 'operator') {
$out .= '<div class="mt-3">';
$out .= '<p class="text-muted small">You are the operator. Your Ğ1 public key is stored in your channel settings, not in config.</p>';
if ($balance !== '') {
$out .= '<p class="mb-1"><strong>Balance:</strong> ' . g1wallet_h($balance) . ' Ğ1</p>';
} else {
$out .= '<p class="text-muted small">Balance unavailable.</p>';
}
$out .= '</div>';
}
// Address registration form
$out .= '<div class="g1wallet-address-form">';
$out .= '<h5>' . ($stored_addr ? 'Update Address' : 'Register Your Ğ1 Address') . '</h5>';
$out .= '<p class="text-muted small">Paste your Ğ1 address (starts with <code>g1</code>, 4647 characters). ';
$out .= 'You can find it in the Ğecko app or any Ğ1 wallet application.</p>';
$out .= '<form method="post" action="/g1wallet/address">';
$out .= g1wallet_csrf_token();
$out .= '<div class="mb-3">';
$out .= '<label class="form-label" for="g1wallet-address">Ğ1 Address <span class="text-danger">*</span></label>';
$out .= '<input type="text" class="form-control font-monospace" id="g1wallet-address" name="g1_address"';
$out .= ' value="' . g1wallet_h($stored_addr) . '"';
$out .= ' placeholder="g1..." autocomplete="off" spellcheck="false" maxlength="48">';
$out .= '</div>';
$out .= '<button type="submit" class="btn btn-primary">Save Address</button>';
$out .= '</form>';
$out .= '</div>';
$out .= '</div>';
return $out;
}
// -----------------------------------------------------------------------------
// BALANCE FETCH
// -----------------------------------------------------------------------------
function g1wallet_fetch_balance($g1_address) {
// Delegates to cry01 chain query via the orchestrator RPC endpoint.
// Returns formatted balance string (e.g. "12.50") or empty string on failure.
// Reuses the same config/RPC path as cry01.
$cry01_config_raw = @file_get_contents('addon/cry01/config.json');
if ($cry01_config_raw === false) return '';
$cry01_config = json_decode($cry01_config_raw, true);
if (json_last_error() !== JSON_ERROR_NONE) return '';
$rpc_endpoint = rtrim($cry01_config['g1_rpc_endpoint'] ?? '', '/');
if (!$rpc_endpoint) return '';
// Decode SS58 address to 32-byte account ID, build storage key, query RPC.
// These functions live in cry01_substrate.php and cry01_chain.php.
if (!function_exists('cry01_ss58_decode')) {
require_once 'addon/cry01/cry01_substrate.php';
}
if (!function_exists('cry01_storage_key')) {
require_once 'addon/cry01/cry01_chain.php';
}
$account_id = cry01_ss58_decode($g1_address);
if (!$account_id) return '';
$storage_key = cry01_storage_key($account_id);
$raw_result = cry01_rpc_state_get_storage($rpc_endpoint, $storage_key);
if ($raw_result === null) return '';
$account_info = cry01_decode_account_info($raw_result);
if (!$account_info) return '';
// Balance is in centimes (100 units = 1 Ğ1).
$free_centimes = $account_info['free'] ?? '0';
return cry01_format_balance($free_centimes);
}
function cry01_format_balance($centimes_string) {
// Converts centimes string to "X.XX" Ğ1 string using string arithmetic.
if (!function_exists('cry01_decimal_string_add')) {
require_once 'addon/cry01/cry01_substrate.php';
}
// Integer division by 100 via string — avoid bcmath dependency.
$len = strlen($centimes_string);
if ($len <= 2) {
$whole = '0';
$frac = str_pad($centimes_string, 2, '0', STR_PAD_LEFT);
} else {
$whole = substr($centimes_string, 0, $len - 2);
$frac = substr($centimes_string, $len - 2);
}
return $whole . '.' . $frac;
}
// -----------------------------------------------------------------------------
// ERROR
// -----------------------------------------------------------------------------
function g1wallet_render_error($message) {
// Shows a plain-language error. Never shows a blank page or stack trace.
return '<div class="g1wallet-content"><div class="alert alert-danger">'
. g1wallet_h($message)
. '</div></div>';

View File

@@ -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 4647 characters. Reject anything outside that range.
$len = strlen($pubkey);
// Ğ1 SS58 addresses: begin with "g1", 4647 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 4647 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 = '<div class="alert alert-danger">' . g1wallet_h($message) . '</div>';
return $notice . g1wallet_render_landing($access);
}
function g1wallet_render_landing_with_success($access, $message) {
$notice = '<div class="alert alert-success">' . g1wallet_h($message) . '</div>';
return $notice . g1wallet_render_landing($access);
}