improved identity management
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user