128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
// 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) };
|
|
}
|