Updated
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.');
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Public key is required.']);
|
||||||
|
killme();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic length check — Ğ1 public keys are 43–44 characters in base58.
|
// Ğ1 SS58 addresses: base58-encoded, 36 bytes decoded, begin with "g1".
|
||||||
if (strlen($pubkey) < 43 || strlen($pubkey) > 64) {
|
// Encoded length is 46–47 characters. Reject anything outside that range.
|
||||||
return g1wallet_render_error('Invalid public key format.');
|
$len = strlen($pubkey);
|
||||||
|
if ($len < 46 || $len > 48) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Invalid public key format.']);
|
||||||
|
killme();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: store $pubkey in Hubzilla channel settings using set_pconfig() or equivalent.
|
// Must begin with "g1" (SS58 with Ğ1 network prefix).
|
||||||
// Placeholder: log and return success shell.
|
if (strncmp($pubkey, 'g1', 2) !== 0) {
|
||||||
// set_pconfig(local_channel(), 'g1wallet', 'g1_pubkey', $pubkey);
|
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);
|
||||||
|
|
||||||
// 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' => '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'] ?? '';
|
header('Content-Type: application/json');
|
||||||
// ... HTTP relay to orchestrator ...
|
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');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['status' => 'ok', 'note' => 'Broadcast relay not yet implemented.']);
|
echo json_encode($result);
|
||||||
killme();
|
killme();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user