Skip to content

Commit 5101204

Browse files
mizchiclaude
andcommitted
feat: add GitHub SSH key verification for sender identity
Verify that a relay sender's Ed25519 public key is listed in their GitHub SSH keys (https://github.com/{username}.keys). Verification is opt-in via POST /api/v1/key/verify-github and results are exposed in key/info. Key rotation resets the verification state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16bf3a7 commit 5101204

5 files changed

Lines changed: 1389 additions & 8 deletions

File tree

src/cloudflare_worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ export class RelayRoom {
289289
pathname === '/api/v1/publish' ||
290290
pathname === '/api/v1/inbox/ack' ||
291291
pathname === '/api/v1/presence/heartbeat' ||
292+
pathname === '/api/v1/key/rotate' ||
293+
pathname === '/api/v1/key/verify-github' ||
292294
(pathname === '/api/v1/presence' && request.method === 'DELETE')
293295
) {
294296
await this.state.storage.put(SNAPSHOT_KEY, this.service.snapshot());

src/github_keys.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { base64UrlEncode } from './signing.ts';
2+
3+
const SSH_ED25519_PREFIX = 'ssh-ed25519';
4+
const ED25519_RAW_KEY_LENGTH = 32;
5+
6+
/**
7+
* Parse a single OpenSSH authorized_keys line for an Ed25519 key.
8+
* Returns the raw 32-byte public key, or null if not Ed25519 or malformed.
9+
*
10+
* Wire format: [4-byte len]["ssh-ed25519"][4-byte len][32-byte raw key]
11+
*/
12+
export function parseOpenSshEd25519Line(line: string): Uint8Array | null {
13+
const trimmed = line.trim();
14+
if (trimmed.length === 0 || trimmed.startsWith('#')) return null;
15+
16+
const parts = trimmed.split(/\s+/);
17+
if (parts.length < 2) return null;
18+
if (parts[0] !== SSH_ED25519_PREFIX) return null;
19+
20+
let decoded: Uint8Array;
21+
try {
22+
const binary = atob(parts[1]);
23+
decoded = new Uint8Array(binary.length);
24+
for (let i = 0; i < binary.length; i++) {
25+
decoded[i] = binary.charCodeAt(i);
26+
}
27+
} catch {
28+
return null;
29+
}
30+
31+
// Minimum: 4 + 11 ("ssh-ed25519") + 4 + 32 = 51 bytes
32+
if (decoded.length < 51) return null;
33+
34+
const view = new DataView(decoded.buffer, decoded.byteOffset, decoded.byteLength);
35+
36+
// First field: key type string
37+
const typeLen = view.getUint32(0);
38+
if (typeLen !== 11) return null; // "ssh-ed25519" is 11 bytes
39+
const typeStr = new TextDecoder().decode(decoded.subarray(4, 4 + typeLen));
40+
if (typeStr !== SSH_ED25519_PREFIX) return null;
41+
42+
// Second field: raw key
43+
const keyOffset = 4 + typeLen;
44+
if (keyOffset + 4 > decoded.length) return null;
45+
const keyLen = view.getUint32(keyOffset);
46+
if (keyLen !== ED25519_RAW_KEY_LENGTH) return null;
47+
48+
const keyStart = keyOffset + 4;
49+
if (keyStart + keyLen > decoded.length) return null;
50+
51+
return decoded.slice(keyStart, keyStart + keyLen);
52+
}
53+
54+
/**
55+
* Parse multiple lines of authorized_keys text, extracting only Ed25519 keys.
56+
*/
57+
export function parseOpenSshEd25519Keys(text: string): Uint8Array[] {
58+
const keys: Uint8Array[] = [];
59+
for (const line of text.split('\n')) {
60+
const key = parseOpenSshEd25519Line(line);
61+
if (key) keys.push(key);
62+
}
63+
return keys;
64+
}
65+
66+
export interface GitHubKeyFetchResult {
67+
ok: boolean;
68+
keys: Uint8Array[];
69+
error?: string;
70+
}
71+
72+
/**
73+
* Fetch Ed25519 public keys from GitHub for a given username.
74+
* Uses the public endpoint https://github.com/{username}.keys
75+
*/
76+
export async function fetchGitHubEd25519Keys(
77+
username: string,
78+
fetchFn: typeof globalThis.fetch = globalThis.fetch,
79+
): Promise<GitHubKeyFetchResult> {
80+
try {
81+
const res = await fetchFn(`https://github.com/${encodeURIComponent(username)}.keys`);
82+
if (!res.ok) {
83+
return { ok: false, keys: [], error: `github returned ${res.status}` };
84+
}
85+
const text = await res.text();
86+
return { ok: true, keys: parseOpenSshEd25519Keys(text) };
87+
} catch (err) {
88+
const message = err instanceof Error ? err.message : 'fetch failed';
89+
return { ok: false, keys: [], error: message };
90+
}
91+
}
92+
93+
/**
94+
* Check if a relay public key (base64url-encoded) matches any of the provided
95+
* GitHub Ed25519 raw keys.
96+
*/
97+
export function matchesGitHubKey(
98+
relayPublicKeyBase64Url: string,
99+
githubEd25519Keys: Uint8Array[],
100+
): boolean {
101+
let relayBytes: Uint8Array;
102+
try {
103+
// Decode base64url to raw bytes
104+
const normalized = relayPublicKeyBase64Url.replace(/-/g, '+').replace(/_/g, '/');
105+
const padLength = (4 - (normalized.length % 4)) % 4;
106+
const padded = normalized + '='.repeat(padLength);
107+
const binary = atob(padded);
108+
relayBytes = new Uint8Array(binary.length);
109+
for (let i = 0; i < binary.length; i++) {
110+
relayBytes[i] = binary.charCodeAt(i);
111+
}
112+
} catch {
113+
return false;
114+
}
115+
116+
if (relayBytes.length !== ED25519_RAW_KEY_LENGTH) return false;
117+
118+
for (const ghKey of githubEd25519Keys) {
119+
if (ghKey.length !== ED25519_RAW_KEY_LENGTH) continue;
120+
let match = true;
121+
for (let i = 0; i < ED25519_RAW_KEY_LENGTH; i++) {
122+
if (relayBytes[i] !== ghKey[i]) {
123+
match = false;
124+
break;
125+
}
126+
}
127+
if (match) return true;
128+
}
129+
return false;
130+
}
131+
132+
export interface GitHubVerifyResult {
133+
verified: boolean;
134+
error?: string;
135+
}
136+
137+
/**
138+
* High-level verification: check if a relay public key belongs to a GitHub user.
139+
* Fetches the user's SSH keys from GitHub and checks for a match.
140+
*/
141+
export async function verifyKeyAgainstGitHub(
142+
relayPublicKeyBase64Url: string,
143+
githubUsername: string,
144+
fetchFn?: typeof globalThis.fetch,
145+
): Promise<GitHubVerifyResult> {
146+
const result = await fetchGitHubEd25519Keys(githubUsername, fetchFn);
147+
if (!result.ok) {
148+
return { verified: false, error: result.error };
149+
}
150+
if (result.keys.length === 0) {
151+
return { verified: false, error: 'no ed25519 keys found for github user' };
152+
}
153+
const matched = matchesGitHubKey(relayPublicKeyBase64Url, result.keys);
154+
return { verified: matched };
155+
}
156+
157+
// Re-export for convenience (used to build mock test data)
158+
export { base64UrlEncode };

0 commit comments

Comments
 (0)