feat(extension): add SSH SHA256 fingerprint util (webcrypto)
This commit is contained in:
30
extension/src/shared/__tests__/ssh-fingerprint.test.ts
Normal file
30
extension/src/shared/__tests__/ssh-fingerprint.test.ts
Normal file
@@ -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:<b64>', 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);
|
||||
});
|
||||
});
|
||||
32
extension/src/shared/ssh-fingerprint.ts
Normal file
32
extension/src/shared/ssh-fingerprint.ts
Normal file
@@ -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:<base64-no-pad of SHA256(decoded-key-blob)>
|
||||
|
||||
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<string | null> {
|
||||
if (!publicKey) return null;
|
||||
const parts = publicKey.trim().split(/\s+/);
|
||||
if (parts.length < 2) return null; // need "<algo> <blob>"
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user