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<string | null> => {
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<string | null> => {
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<string, number> = {
"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<V> {
uri: string;
rkey: string;
value: V;
}
interface ListResponse {
cursor?: string;
items: { uri: string; cid?: string; value: unknown }[];
}
const listRecords = async <V>(
ctx: BobbinContext,
nsid: string,
did: string,
init?: XrpcRequestInit
): Promise<ListedRecord<V>[]> => {
const res = await jsonGet<ListResponse>(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<PublicKeyRecord>(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<CreatedPubKey> => {
// 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 };
};