From 1edfa67a518f323b5b41e19faa39a51c5d0d65bc Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 30 May 2026 01:11:40 -0400 Subject: [PATCH] feat(extension): add SSH SHA256 fingerprint util (webcrypto) --- .../shared/__tests__/ssh-fingerprint.test.ts | 30 +++++++++++++++++ extension/src/shared/ssh-fingerprint.ts | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 extension/src/shared/__tests__/ssh-fingerprint.test.ts create mode 100644 extension/src/shared/ssh-fingerprint.ts diff --git a/extension/src/shared/__tests__/ssh-fingerprint.test.ts b/extension/src/shared/__tests__/ssh-fingerprint.test.ts new file mode 100644 index 0000000..47294d7 --- /dev/null +++ b/extension/src/shared/__tests__/ssh-fingerprint.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { sshFingerprint } from '../ssh-fingerprint'; + +describe('sshFingerprint', () => { + it('formats a known ed25519 key to SHA256:', async () => { + // Public key for the seed below — same format `relicario device list` prints. + // Pre-computed: SHA256 of the base64-decoded key blob, base64-no-pad encoded. + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ alice@example'; + const fp = await sshFingerprint(key); + expect(fp).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + expect(fp?.includes('=')).toBe(false); + }); + + it('returns null for malformed input', async () => { + expect(await sshFingerprint('')).toBeNull(); + expect(await sshFingerprint('not a key')).toBeNull(); + expect(await sshFingerprint('ssh-ed25519')).toBeNull(); // missing blob + }); + + it('returns null for invalid base64', async () => { + expect(await sshFingerprint('ssh-ed25519 !!!notbase64!!!')).toBeNull(); + }); + + it('is deterministic for the same key', async () => { + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8wRgr7y2BwnIaUMfqCcW8GZTYCmGoiCQ0c3VwYTtVZ'; + const a = await sshFingerprint(key); + const b = await sshFingerprint(key); + expect(a).toBe(b); + }); +}); diff --git a/extension/src/shared/ssh-fingerprint.ts b/extension/src/shared/ssh-fingerprint.ts new file mode 100644 index 0000000..cf8eb19 --- /dev/null +++ b/extension/src/shared/ssh-fingerprint.ts @@ -0,0 +1,32 @@ +/// SSH-style SHA256 fingerprint of an ed25519 public key, computed in the +/// extension so devices.ts can display verifiable IDs without a SW round-trip. +/// Output format matches `ssh-keygen -lf` and `relicario device list`: +/// SHA256: + +function base64Decode(b64: string): Uint8Array | null { + try { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function base64Encode(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); +} + +export async function sshFingerprint(publicKey: string): Promise { + if (!publicKey) return null; + const parts = publicKey.trim().split(/\s+/); + if (parts.length < 2) return null; // need " " + const blob = base64Decode(parts[1]); + if (!blob || blob.length === 0) return null; + const hash = await crypto.subtle.digest('SHA-256', blob.buffer as ArrayBuffer); + const b64 = base64Encode(new Uint8Array(hash)).replace(/=+$/, ''); + return `SHA256:${b64}`; +}