This commit is contained in:
2026-06-14 03:52:08 -04:00
parent e105d191c3
commit a44ab4b760
3 changed files with 148 additions and 53 deletions

View File

@@ -3,7 +3,7 @@
/** /**
* Name: Ğ1 Wallet * 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. * Description: Self-sovereign Ğ1 wallet for SASE-verified participants. Key derivation and signing in the browser. The platform never touches your keys.
* Version: 0.1.0 * Version: 0.2.0
* MinVersion: 11.0 * MinVersion: 11.0
* MaxVersion: 12.0 * MaxVersion: 12.0
*/ */
@@ -36,9 +36,9 @@ function g1wallet_load_pdl(&$b) {
} }
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// HELPERS // HELPERS
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_h($value) { function g1wallet_h($value) {
// HTML-escapes a value for safe output. // HTML-escapes a value for safe output.
@@ -53,9 +53,9 @@ function g1wallet_load_config() {
return (json_last_error() === JSON_ERROR_NONE) ? $cfg : []; return (json_last_error() === JSON_ERROR_NONE) ? $cfg : [];
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// ACCESS // ACCESS
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_access_state() { function g1wallet_access_state() {
// Returns operator, participant, or public. // Returns operator, participant, or public.
@@ -80,13 +80,13 @@ function g1wallet_access_state() {
$associations = $cfg['associations'] ?? []; $associations = $cfg['associations'] ?? [];
if (empty($associations)) return 'public'; if (empty($associations)) return 'public';
// Direct pgrp_member query — works for guest tokens. // Direct pggrp_member query — works for guest tokens.
foreach ($associations as $slug => $assoc) { foreach ($associations as $slug => $assoc) {
$groups = $assoc['groups'] ?? []; $groups = $assoc['groups'] ?? [];
foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) { foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) {
$gid = intval($groups[$group_key] ?? 0); $gid = intval($groups[$group_key] ?? 0);
if ($gid) { if ($gid) {
$r = q("SELECT xchan FROM pgrp_member WHERE gid = %d AND xchan = '%s' LIMIT 1", $r = q("SELECT xchan FROM pggrp_member WHERE gid = %d AND xchan = '%s' LIMIT 1",
intval($gid), intval($gid),
dbesc($observer) dbesc($observer)
); );
@@ -98,17 +98,22 @@ function g1wallet_access_state() {
return 'public'; return 'public';
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// CONTENT ROUTER // CONTENT ROUTER
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_content() { function g1wallet_content() {
if (function_exists('head_add_css')) { if (function_exists('head_add_css')) {
head_add_css('/addon/g1wallet/view/css/g1wallet.css'); head_add_css('/addon/g1wallet/view/css/g1wallet.css');
} }
if (function_exists('head_add_js')) { if (function_exists('head_add_js')) {
// bip39 must load before g1wallet.js (g1wallet.js calls window.bip39).
head_add_js('/addon/g1wallet/vendor/bip39-3.1.0.min.js'); head_add_js('/addon/g1wallet/vendor/bip39-3.1.0.min.js');
head_add_js('/addon/g1wallet/view/js/g1wallet.js'); head_add_js('/addon/g1wallet/view/js/g1wallet.js');
// Note: vendor/scrypt-js-3.0.1.min.js is NOT loaded.
// scrypt is the obsolete Cesium1 / Duniter v1 derivation algorithm.
// Duniter v2 / Ğecko uses entropy-as-seed (no KDF). The file is
// retained in vendor/ for reference but is not wired anywhere.
} }
$access = g1wallet_access_state(); $access = g1wallet_access_state();
@@ -136,7 +141,7 @@ function g1wallet_content() {
return g1wallet_handle_broadcast_post(); return g1wallet_handle_broadcast_post();
case 'pubkey': case 'pubkey':
// POST: store participant's public key in channel settings after unlock. // POST: store public key in channel settings after unlock.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return g1wallet_render_error('POST required.'); return g1wallet_render_error('POST required.');
} }
@@ -151,9 +156,9 @@ function g1wallet_content() {
} }
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// CSRF // CSRF
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_csrf_token() { function g1wallet_csrf_token() {
// Generates and stores a CSRF token for the current session. // Generates and stores a CSRF token for the current session.

View File

@@ -6,33 +6,31 @@
* Knows nothing about crypto — that lives entirely in g1wallet.js. * Knows nothing about crypto — that lives entirely in g1wallet.js.
*/ */
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// ACCESS WALL // ACCESS WALL
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_render_access_wall() { function g1wallet_render_access_wall() {
$directory_url = g1wallet_h(z_root() . '/channel/theron');
return ' return '
<div class="g1wallet-content"> <div class="g1wallet-content">
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<strong>SASE verification required to access the Ğ1 Wallet.</strong> <strong>SASE verification required to access the Ğ1 Wallet.</strong>
This wallet is available to verified HOA participants only. This wallet is available to verified HOA participants only.
To participate, you must complete the SASE process. To participate, you must complete the SASE process.
Visit <a href="https://directory.diagnostics.kane-il.us/channel/theron"> Visit <a href="' . $directory_url . '">' . g1wallet_h(App::get_hostname()) . '</a> to begin.
directory.diagnostics.kane-il.us
</a> to begin.
</div> </div>
</div> </div>
'; ';
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// WALLET LANDING // WALLET LANDING
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_render_landing($access) { function g1wallet_render_landing($access) {
// Wallet landing: shows unlock form or unlocked interface depending on JS session state. // Wallet landing: shows unlock form or unlocked interface depending on JS session state.
// At skeleton stage, always shows the unlock form. // JS swaps to the unlocked view on successful derivation.
// Once g1wallet.js is wired, the JS will swap to the unlocked view on successful derivation.
$out = '<div class="g1wallet-content">'; $out = '<div class="g1wallet-content">';
$out .= '<div class="g1wallet-header mb-3">'; $out .= '<div class="g1wallet-header mb-3">';
@@ -63,18 +61,21 @@ function g1wallet_render_landing($access) {
return $out; return $out;
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// UNLOCK FORM // UNLOCK FORM
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_render_unlock_form() { function g1wallet_render_unlock_form() {
// Renders the wallet unlock form. // Renders the wallet unlock form.
// The form is handled entirely by g1wallet.js — it does NOT POST to the server. // The form is handled entirely by g1wallet.js — it does NOT POST to the server.
// The mnemonic never leaves the browser. // The mnemonic never leaves the browser.
// //
// Per Duniter HD Wallet RFC 0015 (Dubp_HD_Wallet), the wallet's keypair is // Derivation (Duniter v2 / Substrate / Ğecko standard):
// derived from a 12-word BIP39 mnemonic (English wordlist), using its // 12-word BIP39 mnemonic
// entropy as input to a BIP32-Ed25519 derivation — not a raw PBKDF2 seed. // → 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 = '<div class="g1wallet-unlock-form">';
$out .= '<h4>Unlock Your Wallet</h4>'; $out .= '<h4>Unlock Your Wallet</h4>';
@@ -99,13 +100,13 @@ function g1wallet_render_unlock_form() {
return $out; return $out;
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// UNLOCKED INTERFACE (PLACEHOLDER) // UNLOCKED INTERFACE
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_render_unlocked_placeholder($access) { function g1wallet_render_unlocked_placeholder($access) {
// Placeholder for the unlocked wallet interface. // Unlocked wallet interface.
// Populated by g1wallet.js once key derivation is implemented. // Populated by g1wallet.js after key derivation.
$out = '<div class="g1wallet-unlocked">'; $out = '<div class="g1wallet-unlocked">';
$out .= '<div class="alert alert-success d-flex justify-content-between align-items-center">'; $out .= '<div class="alert alert-success d-flex justify-content-between align-items-center">';
@@ -114,7 +115,7 @@ function g1wallet_render_unlocked_placeholder($access) {
$out .= '</div>'; $out .= '</div>';
$out .= '<div class="mb-3">'; $out .= '<div class="mb-3">';
$out .= '<h5 class="g1wallet-section-label">Public Key</h5>'; $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 .= '<p id="g1wallet-pubkey-display" class="font-monospace text-muted small">—</p>';
$out .= '</div>'; $out .= '</div>';
@@ -141,9 +142,9 @@ function g1wallet_render_unlocked_placeholder($access) {
return $out; return $out;
} }
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// ERROR // ERROR
// ---------------------------------------------------------------------------- // -----------------------------------------------------------------------------
function g1wallet_render_error($message) { function g1wallet_render_error($message) {
// Shows a plain-language error. Never shows a blank page or stack trace. // Shows a plain-language error. Never shows a blank page or stack trace.

View File

@@ -2,36 +2,72 @@
/** /**
* g1wallet_spool.php — POST handlers for g1wallet. * g1wallet_spool.php — POST handlers for g1wallet.
* pubkey store: receives public key after unlock, stores in channel settings. * pubkey store: receives public key (SS58 g1... address) after unlock, stores in channel settings.
* broadcast relay: receives signed Duniter transaction, relays to orchestrator. * broadcast relay: receives signed Duniter transaction, relays to orchestrator.
* *
* At skeleton stage both handlers return placeholder responses.
* The private key never reaches this file. Ever. * The private key never reaches this file. Ever.
*/ */
function g1wallet_handle_pubkey_post($access) { function g1wallet_handle_pubkey_post($access) {
// Stores the participant's Ğ1 public key in their Hubzilla channel settings. // Stores the participant's Ğ1 public key (SS58 address) in their Hubzilla
// Called once after first wallet unlock (and on re-unlock if key changes). // 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. // The public key is the only wallet-related thing the server ever stores.
$pubkey = trim($_POST['g1_pubkey'] ?? ''); $pubkey = trim($_POST['g1_pubkey'] ?? '');
if (!$pubkey) { if (!$pubkey) {
return g1wallet_render_error('Public key is required.');
}
// Basic length check — Ğ1 public keys are 4344 characters in base58.
if (strlen($pubkey) < 43 || strlen($pubkey) > 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'); header('Content-Type: application/json');
echo json_encode(['status' => 'ok', 'note' => 'Pubkey storage not yet implemented.']); echo json_encode(['status' => 'error', 'message' => 'Public key is required.']);
killme();
}
// Ğ1 SS58 addresses: base58-encoded, 36 bytes decoded, begin with "g1".
// Encoded length is 4647 characters. Reject anything outside that range.
$len = strlen($pubkey);
if ($len < 46 || $len > 48) {
header('Content-Type: application/json');
echo json_encode(['status' => 'error', 'message' => 'Invalid public key format.']);
killme();
}
// 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();
}
// 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();
}
// 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();
}
// 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();
}
// Store the public key.
set_pconfig($channel_id, 'g1wallet', 'g1_pubkey', $pubkey);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);
killme(); killme();
} }
@@ -51,13 +87,66 @@ function g1wallet_handle_broadcast_post() {
killme(); killme();
} }
// TODO: load config, relay to orchestrator POST /g1wallet/broadcast. // Validate doc_type is a known type.
// $config = g1wallet_load_config(); $allowed_types = ['transfer', 'certification'];
// $orchestrator_url = $config['orchestrator_url'] ?? ''; if (!in_array($doc_type, $allowed_types, true)) {
// $node_token = $config['node_token'] ?? '';
// ... HTTP relay to orchestrator ...
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['status' => 'ok', 'note' => 'Broadcast relay not yet implemented.']); 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(); killme();
} }