import { now as tidNow } from "@atcute/tid"; import type { Cid, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { jsonGet } from "#lib/api/_request.ts"; import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts"; import { nsidOf, recordKeyOf } from "#lib/api/syntax.ts"; import { rkeyFromUri } from "#lib/api/uri.ts"; import { createRecord, deleteRecord } from "#lib/api/write.ts"; import type * as OrgTangledKeySshKey from "#lib/api/lexicons/types/org/tangled/key/sshKey.ts"; export type PublicKeyRecord = OrgTangledKeySshKey.Main; const PUBLIC_KEY_COLLECTION = nsidOf("org.tangled.key.sshKey"); // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. export const sshFingerprint = async (pubkey: string): Promise => { const parts = pubkey.trim().split(/\s+/); const blob = parts.length >= 2 ? parts[1] : parts[0]; try { const bytes = Uint8Array.from(atob(blob), (character) => character.charCodeAt(0)); const digest = await crypto.subtle.digest("SHA-256", bytes); const encoded = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/=+$/, ""); return `SHA256:${encoded}`; } catch { return null; } }; export const ensureNewSshKey = async ( key: string, existing: readonly (string | null | undefined)[] ): Promise => { const fingerprint = await sshFingerprint(key); if (fingerprint && existing.includes(fingerprint)) { throw new Error(`This key is already added (${fingerprint}).`); } return fingerprint; }; const SSH_KEY_FIELDS: Record = { "ssh-ed25519": 2, "ssh-rsa": 3, "ssh-dss": 5, "ecdsa-sha2-nistp256": 3, "ecdsa-sha2-nistp384": 3, "ecdsa-sha2-nistp521": 3, "sk-ssh-ed25519@openssh.com": 3, "sk-ecdsa-sha2-nistp256@openssh.com": 4 }; const ED25519_KEY_BYTES = 32; const sshFields = (bytes: Uint8Array): Uint8Array[] | null => { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const fields: Uint8Array[] = []; let offset = 0; while (offset < bytes.length) { if (offset + 4 > bytes.length) return null; const length = view.getUint32(offset); offset += 4; if (offset + length > bytes.length) return null; fields.push(bytes.subarray(offset, offset + length)); offset += length; } return fields; }; export const isSshPublicKey = (pubkey: string): boolean => { const [type, blob] = pubkey.trim().split(/\s+/); if (!type || !blob) return false; const expected = SSH_KEY_FIELDS[type]; if (!expected) return false; let bytes: Uint8Array; try { bytes = Uint8Array.from(atob(blob), (character) => character.charCodeAt(0)); } catch { return false; } const fields = sshFields(bytes); if (!fields || fields.length !== expected) return false; if (new TextDecoder().decode(fields[0]) !== type) return false; if (type.includes("ed25519") && fields[1].length !== ED25519_KEY_BYTES) return false; return fields.every((field) => field.length > 0); }; export interface ListedRecord { uri: string; rkey: string; value: V; } interface ListResponse { cursor?: string; items: { uri: string; cid?: string; value: unknown }[]; } const listRecords = async ( ctx: BobbinContext, nsid: string, did: string, init?: XrpcRequestInit ): Promise[]> => { const res = await jsonGet(ctx, nsid, { subject: did }, init); return res.items.map((item) => ({ uri: item.uri, rkey: rkeyFromUri(item.uri), value: item.value as V })); }; export const listPubKeys = (ctx: BobbinContext, did: string, init?: XrpcRequestInit) => listRecords(ctx, "org.tangled.publicKey.listKeys", did, init); export interface CreatedPubKey { uri: ResourceUri; cid: Cid; rkey: string; } export const createPubKey = async ( agent: OAuthUserAgent, name: string, key: string ): Promise => { // the rkey is a tid the client mints, rather than one the pds assigns const rkey = recordKeyOf(tidNow()); const { uri, cid } = await createRecord( agent, PUBLIC_KEY_COLLECTION, { $type: "org.tangled.key.sshKey", name, publicKey: key, createdAt: new Date().toISOString() }, rkey ); return { uri, cid, rkey }; }; export const deletePubKey = async ( agent: OAuthUserAgent, rkey: string ): Promise<{ uri: ResourceUri }> => { const uri = `at://${agent.sub}/${PUBLIC_KEY_COLLECTION}/${rkey}` as ResourceUri; await deleteRecord(agent, PUBLIC_KEY_COLLECTION, recordKeyOf(rkey)); return { uri }; };