diff --git a/dashboard/dist/index.html b/dashboard/dist/index.html index 7368629..78e8c47 100644 --- a/dashboard/dist/index.html +++ b/dashboard/dist/index.html @@ -21,7 +21,7 @@ min-height: 100vh; } - +
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index cb9b130..e69299c 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,6 +8,7 @@ "name": "@meta-core/dashboard", "version": "1.0.0", "dependencies": { + "@noble/secp256k1": "^3.1.0", "iso-639-3": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -744,6 +745,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/secp256k1": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz", + "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 6b8580a..cbbaa5f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,10 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "@noble/secp256k1": "^3.1.0", + "iso-639-3": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0", - "iso-639-3": "^3.0.1" + "react-router-dom": "^6.20.0" }, "devDependencies": { "@types/react": "^18.2.38", diff --git a/dashboard/src/lib/sign.ts b/dashboard/src/lib/sign.ts new file mode 100644 index 0000000..bbcb6bd --- /dev/null +++ b/dashboard/src/lib/sign.ts @@ -0,0 +1,127 @@ +// Client-side proof of possession for account operations. +// +// meta-core stores every private key in plaintext, so it can sign as anybody — +// which means a signature it produced proves nothing about who asked. The only +// signature that carries authority is one made where the key actually is, and +// for a browser-driven admin console that is here. meta-core enforces the other +// half by refusing to sign anything in the challenge domain +// (identity.ChallengeDomain), so this path cannot be short-circuited. +// +// The secret key entered on this page is used for exactly one signature and is +// never sent anywhere: `/api/identity/challenge` receives only the uid, and the +// request that follows carries only the challenge and its signature. +// +// Same scheme, same curve and same encodings as meta-watch's sign-in +// (packages/meta-watch/ui/js/auth.js) — deliberately, so one implementation on +// the server verifies both. + +import * as secp from '@noble/secp256k1'; + +export type IdentityAction = 'reveal' | 'delete'; + +function hexToBytes(hex: string): Uint8Array | null { + let h = hex.trim(); + if (h.startsWith('0x') || h.startsWith('0X')) h = h.slice(2); + if (h.length !== 64 || /[^0-9a-fA-F]/.test(h)) return null; + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); + return out; +} + +function bytesToBase64(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} + +const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +/// base58btc, the multibase "z" alphabet. Spelled out rather than pulled in: +/// the alternative is a second dependency for thirty lines of arithmetic. +function base58Encode(bytes: Uint8Array): string { + const digits: number[] = [0]; + for (const byte of bytes) { + let carry = byte; + for (let i = 0; i < digits.length; i++) { + carry += digits[i] << 8; + digits[i] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + // Leading zero bytes encode as leading '1's rather than being dropped. + let out = ''; + for (let i = 0; i < bytes.length && bytes[i] === 0; i++) out += B58[0]; + for (let i = digits.length - 1; i >= 0; i--) out += B58[digits[i]]; + return out; +} + +/// The uid a secret key belongs to: "z" + base58btc(33-byte compressed pubkey). +/// Derived locally so a mistyped or wrong-account key is caught before any +/// request goes out. Mirrors `identityFromPrivKey` in meta-core's identity.go. +export function uidFromSecretKey(secretKeyHex: string): string | null { + const raw = hexToBytes(secretKeyHex); + if (!raw) return null; + try { + return 'z' + base58Encode(secp.getPublicKey(raw, true)); + } catch { + return null; // not a valid scalar + } +} + +/// Whether this page can sign at all. +/// +/// noble's signAsync takes its sha256 from crypto.subtle, which browsers expose +/// only in a secure context. The dashboard behind Caddy is https and fine; the +/// debug-direct port (18083) is plain http, where every signature would fail +/// with a cryptic error unless we say so first. +export function canSign(): boolean { + return Boolean(globalThis.crypto?.subtle); +} + +export const INSECURE_CONTEXT_MESSAGE = + 'This page cannot sign over a plain-http connection — browsers only expose the ' + + 'needed crypto on https (or localhost). Reach the dashboard through Caddy ' + + '(https://metacore-dev.localhost:8083) rather than the debug-direct port.'; + +/// Ask meta-core for a challenge, sign it locally, and hand back the proof. +/// +/// Throws with a message fit to show the user. The caller passes the proof +/// straight into the reveal/delete request body. +export async function proveOwnership( + uid: string, + action: IdentityAction, + secretKeyHex: string +): Promise<{ challenge: string; signature: string }> { + if (!canSign()) throw new Error(INSECURE_CONTEXT_MESSAGE); + + const raw = hexToBytes(secretKeyHex); + if (!raw) { + throw new Error('That is not a valid secret key: expected 64 hex characters.'); + } + const derived = uidFromSecretKey(secretKeyHex); + if (!derived) throw new Error('That is not a valid secp256k1 secret key.'); + if (derived !== uid) { + // Caught here rather than by the server, so the message can say which + // account the key actually is — the useful half of the answer. + throw new Error(`That key belongs to a different account (${derived}), not this one.`); + } + + const cRes = await fetch('/api/identity/challenge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid, action }), + }); + const cBody = await cRes.json().catch(() => ({})); + if (!cRes.ok || !cBody.challenge) { + throw new Error(cBody.message ?? `Could not start verification (HTTP ${cRes.status}).`); + } + + // signAsync prehashes with sha256 and returns a 64-byte compact (r‖s) + // signature — noble v3 dropped DER. meta-core's identity.Verify accepts both. + const sig = await secp.signAsync(new TextEncoder().encode(cBody.challenge), raw); + return { challenge: cBody.challenge, signature: bytesToBase64(sig) }; +} diff --git a/dashboard/src/pages/Identity.tsx b/dashboard/src/pages/Identity.tsx index 57d48a4..14ee46a 100644 --- a/dashboard/src/pages/Identity.tsx +++ b/dashboard/src/pages/Identity.tsx @@ -1,5 +1,13 @@ import { useEffect, useRef, useState } from 'react'; +import { + INSECURE_CONTEXT_MESSAGE, + canSign, + proveOwnership, + uidFromSecretKey, + type IdentityAction, +} from '../lib/sign'; + // Admin console for meta-core's account keystore. // // meta-core stores one secp256k1 keypair per file under @@ -10,6 +18,13 @@ import { useEffect, useRef, useState } from 'react'; // The private key is returned by exactly two calls: POST /api/identity/generate // (once, at creation) and POST /api/identity/reveal (on demand, for an account // that already exists). Both land in the same exclusive SecretKeyPanel. +// +// Reveal and remove both require the account's own secret key, entered here and +// used to sign a one-shot challenge locally — the key itself is never sent. So +// this page can enumerate and create, but it holds no authority over an +// existing account that its operator does not already have the key for. There +// is deliberately no admin override: on this node the private key *is* the +// account. interface Account { uid: string; @@ -17,6 +32,14 @@ interface Account { createdAt: number; } +// What one account holds in the User Data Layer. Absent from +// /api/udl/users/stats means zero, not unknown. +interface Stats { + records: number; + cids: number; + keys: number; +} + // A secret on screen. Carries no createdAt: /api/identity/reveal answers with // {uid, curve, privateKeyHex} only. interface SecretKeyView { @@ -32,6 +55,14 @@ interface Pending { kind: 'remove' | 'reveal'; } +// Both confirmable operations map to a server-side action name, and the +// challenge is bound to it: a challenge minted to reveal a key cannot delete +// the account. Keeping the mapping in one place stops the two drifting. +const ACTION_FOR: RecordCurve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}
++ {/* An account that has never been used legitimately holds nothing, so + zero is a real answer — but only once the count actually ran. */} + {!counted + ? 'Stored data: could not be counted (storage unavailable)' + : records === 0 + ? 'No stored data' + : `${records.toLocaleString()} stored ${records === 1 ? 'record' : 'records'}` + + ` across ${titles.toLocaleString()} ${titles === 1 ? 'title' : 'titles'}`} +
- Admin page — no authorisation happens here. /api/identity/*{' '}
- asks no questions: anything that can reach this API can list, create, reveal or delete{' '}
- any account's private key. The only access control is the perimeter in front of
- this port. Client-facing screens (meta-watch) authorise reveal and delete against the
- caller's own session and expose no delete at all; this page has neither check. Treat
- reaching it as equivalent to holding every key on this node.
+ The key is the only authority. Revealing or removing an account requires
+ that account's secret key, entered here and used to sign a one-time challenge in your
+ browser — there is no admin override, and holding this page is not one. Listing and
+ creating accounts are still open to anything that can reach this API, so the perimeter in
+ front of this port is what keeps the roster private.
+
+ What this does not defend against: anyone with filesystem access to{' '}
+ {'{META_CORE_PATH}'}/identity/accounts/ reads every private key directly.
+ The keys are stored in plaintext, matching the trust model of the rest of{' '}
+ /meta-core.
{notice}
+