Initial push
This commit is contained in:
45
hubzilla/addon/g1wallet/README.md
Normal file
45
hubzilla/addon/g1wallet/README.md
Normal file
@@ -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`.
|
||||
171
hubzilla/addon/g1wallet/g1wallet.php
Normal file
171
hubzilla/addon/g1wallet/g1wallet.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 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.1.0
|
||||
* MinVersion: 11.0
|
||||
* MaxVersion: 12.0
|
||||
*/
|
||||
|
||||
use Zotlabs\Extend\Widget;
|
||||
|
||||
require_once 'addon/g1wallet/g1wallet_renderer.php';
|
||||
require_once 'addon/g1wallet/g1wallet_spool.php';
|
||||
|
||||
function g1wallet_module() {}
|
||||
|
||||
function g1wallet_load() {
|
||||
register_hook('load_pdl', 'addon/g1wallet/g1wallet.php', 'g1wallet_load_pdl');
|
||||
Widget::register('addon/g1wallet/Widget/G1wallet.php', 'g1wallet');
|
||||
}
|
||||
|
||||
function g1wallet_unload() {
|
||||
unregister_hook('load_pdl', 'addon/g1wallet/g1wallet.php', 'g1wallet_load_pdl');
|
||||
Widget::unregister('addon/g1wallet/Widget/G1wallet.php', 'g1wallet');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
$layout = @file_get_contents('addon/g1wallet/mod_g1wallet.pdl');
|
||||
if ($layout !== false) {
|
||||
$b['layout'] = $layout;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HELPERS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
return (json_last_error() === JSON_ERROR_NONE) ? $cfg : [];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ACCESS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
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().
|
||||
if (local_channel()) {
|
||||
$channel = App::get_channel();
|
||||
if (local_channel() === intval($channel['channel_id'])) {
|
||||
return 'operator';
|
||||
}
|
||||
}
|
||||
|
||||
$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);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) return 'public';
|
||||
|
||||
$associations = $cfg['associations'] ?? [];
|
||||
if (empty($associations)) return 'public';
|
||||
|
||||
// Direct pgrp_member query — works for guest tokens.
|
||||
foreach ($associations as $slug => $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 '<input type="hidden" name="g1wallet_csrf" value="'
|
||||
. g1wallet_h($_SESSION['g1wallet_csrf']) . '">';
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
158
hubzilla/addon/g1wallet/g1wallet_renderer.php
Normal file
158
hubzilla/addon/g1wallet/g1wallet_renderer.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ACCESS WALL
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function g1wallet_render_access_wall() {
|
||||
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="https://directory.diagnostics.kane-il.us/channel/theron">
|
||||
directory.diagnostics.kane-il.us
|
||||
</a> to begin.
|
||||
</div>
|
||||
</div>
|
||||
';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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 = '<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 .= '</div>';
|
||||
|
||||
// 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.
|
||||
// Pseudo and password never leave the browser.
|
||||
|
||||
$out = '<div class="g1wallet-unlock-form">';
|
||||
$out .= '<h4>Unlock Your Wallet</h4>';
|
||||
$out .= '<p class="text-muted small">Enter your Ğ1 credentials. These are used only in your browser to derive your keypair. They are never sent to the server.</p>';
|
||||
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="g1wallet-pseudo">Pseudo <span class="text-danger">*</span></label>';
|
||||
$out .= '<input type="text" class="form-control" id="g1wallet-pseudo" name="g1wallet_pseudo"
|
||||
autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="your Ğ1 pseudo">';
|
||||
$out .= '<div class="form-text">Your Ğ1 pseudo — the same one you use in Cesium or Ğecko.</div>';
|
||||
$out .= '</div>';
|
||||
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="g1wallet-password">Password <span class="text-danger">*</span></label>';
|
||||
$out .= '<input type="password" class="form-control" id="g1wallet-password" name="g1wallet_password"
|
||||
autocomplete="current-password"
|
||||
placeholder="your Ğ1 password">';
|
||||
$out .= '<div class="form-text">Your Ğ1 password. Never sent to the server.</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 class="mt-3">';
|
||||
$out .= '<p class="text-muted small">';
|
||||
$out .= 'Don\'t have a Ğ1 account? You can create one using ';
|
||||
$out .= '<a href="https://cesium.app" target="_blank" rel="noopener">Cesium</a> or ';
|
||||
$out .= '<a href="https://gecko.duniter.org" target="_blank" rel="noopener">Ğecko</a>.';
|
||||
$out .= '</p>';
|
||||
$out .= '</div>';
|
||||
|
||||
$out .= '</div>';
|
||||
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 = '<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</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>';
|
||||
$out .= '</div>';
|
||||
}
|
||||
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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>';
|
||||
}
|
||||
63
hubzilla/addon/g1wallet/g1wallet_spool.php
Normal file
63
hubzilla/addon/g1wallet/g1wallet_spool.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* g1wallet_spool.php — POST handlers for g1wallet.
|
||||
* pubkey store: receives public key after unlock, stores in channel settings.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function g1wallet_handle_pubkey_post($access) {
|
||||
// Stores the participant's Ğ1 public key in their Hubzilla channel settings.
|
||||
// Called once after first wallet unlock (and on re-unlock if key changes).
|
||||
// The public key is the only wallet-related thing the server ever stores.
|
||||
|
||||
$pubkey = trim($_POST['g1_pubkey'] ?? '');
|
||||
|
||||
if (!$pubkey) {
|
||||
return g1wallet_render_error('Public key is required.');
|
||||
}
|
||||
|
||||
// Basic length check — Ğ1 public keys are 43–44 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');
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user