~james/core

core/web/src/lib/api/settings.ts -rw-r--r-- 4.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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 };
};