feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator: - Deterministic 5x5 symmetric pattern from fingerprint hash - HSL-derived colors, rendered as inline SVG - Click any identicon to copy its fingerprint to clipboard - Used for participants, user identity, and relay servers Server identity (TOFU — Trust On First Use): - Ping returns server fingerprint (QUIC peer certificate hash) - First contact: auto-saved as known fingerprint - Subsequent pings: compared against known fingerprint - Lock icons: locked (verified), unlocked (new), warning (changed), red (offline) - Fingerprint mismatch shows confirmation dialog before connecting UI updates: - Participants show identicons instead of letter avatars - User identity shows identicon + fingerprint on connect screen - Manage Relays shows identicon per server with lock status - Relay button shows lock icon instead of colored dot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
110
desktop/src/identicon.ts
Normal file
110
desktop/src/identicon.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Deterministic identicon generator — creates a unique symmetric pattern
|
||||
* from a hex fingerprint string, similar to MetaMask's Jazzicon / Ethereum blockies.
|
||||
*
|
||||
* Returns an SVG data URL that can be used as an <img> src.
|
||||
*/
|
||||
|
||||
function hashBytes(hex: string): number[] {
|
||||
const clean = hex.replace(/[^0-9a-fA-F]/g, "");
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < clean.length; i += 2) {
|
||||
bytes.push(parseInt(clean.substring(i, i + 2), 16));
|
||||
}
|
||||
// Pad to at least 16 bytes
|
||||
while (bytes.length < 16) bytes.push(0);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const k = (n: number) => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) =>
|
||||
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
return [
|
||||
Math.round(f(0) * 255),
|
||||
Math.round(f(8) * 255),
|
||||
Math.round(f(4) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
export function generateIdenticon(
|
||||
fingerprint: string,
|
||||
size: number = 36
|
||||
): string {
|
||||
const bytes = hashBytes(fingerprint);
|
||||
|
||||
// Derive colors from first bytes
|
||||
const hue1 = (bytes[0] * 360) / 256;
|
||||
const hue2 = ((bytes[1] * 360) / 256 + 120) % 360;
|
||||
const [r1, g1, b1] = hslToRgb(hue1, 65, 35); // dark bg
|
||||
const [r2, g2, b2] = hslToRgb(hue2, 70, 55); // bright fg
|
||||
|
||||
const bg = `rgb(${r1},${g1},${b1})`;
|
||||
const fg = `rgb(${r2},${g2},${b2})`;
|
||||
|
||||
// 5x5 grid, left-right symmetric (only need 3 columns)
|
||||
const grid: boolean[][] = [];
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const row: boolean[] = [];
|
||||
for (let x = 0; x < 3; x++) {
|
||||
const byteIdx = 2 + y * 3 + x;
|
||||
row.push(bytes[byteIdx % bytes.length] > 128);
|
||||
}
|
||||
// Mirror: col 3 = col 1, col 4 = col 0
|
||||
grid.push([row[0], row[1], row[2], row[1], row[0]]);
|
||||
}
|
||||
|
||||
// Render SVG
|
||||
const cellSize = size / 5;
|
||||
const r = size * 0.12; // border radius
|
||||
let rects = "";
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 5; x++) {
|
||||
if (grid[y][x]) {
|
||||
rects += `<rect x="${x * cellSize}" y="${y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||
<rect width="${size}" height="${size}" rx="${r}" fill="${bg}"/>
|
||||
${rects}
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an <img> element with the identicon.
|
||||
* Click copies the fingerprint to clipboard.
|
||||
*/
|
||||
export function createIdenticonEl(
|
||||
fingerprint: string,
|
||||
size: number = 36,
|
||||
clickToCopy: boolean = true
|
||||
): HTMLImageElement {
|
||||
const img = document.createElement("img");
|
||||
img.src = generateIdenticon(fingerprint, size);
|
||||
img.width = size;
|
||||
img.height = size;
|
||||
img.style.borderRadius = `${size * 0.12}px`;
|
||||
img.style.cursor = clickToCopy ? "pointer" : "default";
|
||||
img.title = fingerprint;
|
||||
|
||||
if (clickToCopy && fingerprint) {
|
||||
img.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(fingerprint).then(() => {
|
||||
img.style.outline = "2px solid #4ade80";
|
||||
setTimeout(() => {
|
||||
img.style.outline = "";
|
||||
}, 600);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return img;
|
||||
}
|
||||
Reference in New Issue
Block a user