feat(extension): update devices.ts for revoked.json + deploy keys

- Add createDeployKey/deleteDeployKey to GiteaHost
- Add RevokedEntry interface and readRevoked() to devices.ts
- Update revokeDevice() to write revoked.json alongside devices.json
- Update router to use new register_device WASM API (private keys internal)
- Pass revokedBy device name when revoking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 12:27:14 -04:00
parent 9845febb74
commit 520f6ec72c
3 changed files with 82 additions and 12 deletions

View File

@@ -1,14 +1,22 @@
/// Device management — reads/writes .relicario/devices.json /// Device management — reads/writes .relicario/devices.json and revoked.json
import type { GitHost } from './git-host'; import type { GitHost } from './git-host';
import type { Device } from '../shared/types'; import type { Device } from '../shared/types';
const DEVICES_PATH = '.relicario/devices.json'; const DEVICES_PATH = '.relicario/devices.json';
const REVOKED_PATH = '.relicario/revoked.json';
interface DevicesFile { interface DevicesFile {
devices: Device[]; devices: Device[];
} }
export interface RevokedEntry {
name: string;
public_key: string;
revoked_at: number; // unix timestamp
revoked_by: string; // name of device that performed the revocation
}
export async function readDevices(gitHost: GitHost): Promise<Device[]> { export async function readDevices(gitHost: GitHost): Promise<Device[]> {
try { try {
const raw = await gitHost.readFile(DEVICES_PATH); const raw = await gitHost.readFile(DEVICES_PATH);
@@ -30,6 +38,25 @@ export async function writeDevices(
await gitHost.writeFile(DEVICES_PATH, bytes, message); await gitHost.writeFile(DEVICES_PATH, bytes, message);
} }
export async function readRevoked(gitHost: GitHost): Promise<RevokedEntry[]> {
try {
const raw = await gitHost.readFile(REVOKED_PATH);
const text = new TextDecoder().decode(raw);
return JSON.parse(text) as RevokedEntry[];
} catch {
return [];
}
}
async function writeRevoked(
gitHost: GitHost,
revoked: RevokedEntry[],
message: string,
): Promise<void> {
const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2));
await gitHost.writeFile(REVOKED_PATH, bytes, message);
}
export async function addDevice( export async function addDevice(
gitHost: GitHost, gitHost: GitHost,
device: Device, device: Device,
@@ -45,11 +72,25 @@ export async function addDevice(
export async function revokeDevice( export async function revokeDevice(
gitHost: GitHost, gitHost: GitHost,
name: string, name: string,
revokedBy?: string,
): Promise<void> { ): Promise<void> {
const existing = await readDevices(gitHost); const existing = await readDevices(gitHost);
const filtered = existing.filter((d) => d.name !== name); const device = existing.find((d) => d.name === name);
if (filtered.length === existing.length) { if (!device) {
throw new Error(`device '${name}' not found`); throw new Error(`device '${name}' not found`);
} }
// Remove from devices.json
const filtered = existing.filter((d) => d.name !== name);
await writeDevices(gitHost, filtered, `device: revoke ${name}`); await writeDevices(gitHost, filtered, `device: revoke ${name}`);
// Add to revoked.json
const revoked = await readRevoked(gitHost);
revoked.push({
name,
public_key: device.public_key,
revoked_at: Math.floor(Date.now() / 1000),
revoked_by: revokedBy ?? 'unknown',
});
await writeRevoked(gitHost, revoked, `device: revoke ${name} (revoked log)`);
} }

View File

@@ -17,6 +17,7 @@ export class GiteaHost implements GitHost {
private baseUrl: string; private baseUrl: string;
private gitApiBase: string; private gitApiBase: string;
private commitsUrl: string; private commitsUrl: string;
private keysUrl: string;
private branch: string = 'main'; private branch: string = 'main';
private headers: Record<string, string>; private headers: Record<string, string>;
@@ -27,6 +28,7 @@ export class GiteaHost implements GitHost {
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`; this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`; this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`; this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
this.keysUrl = `${apiUrl}/repos/${repoPath}/keys`;
this.headers = { this.headers = {
'Authorization': `token ${apiToken}`, 'Authorization': `token ${apiToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -244,4 +246,31 @@ export class GiteaHost implements GitHost {
async deleteBlob(path: string, message: string): Promise<void> { async deleteBlob(path: string, message: string): Promise<void> {
return this.deleteFile(path, message); return this.deleteFile(path, message);
} }
/// Create a deploy key for this repo, returning its numeric ID.
async createDeployKey(title: string, publicKey: string): Promise<number> {
const resp = await fetch(this.keysUrl, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({ title, key: publicKey, read_only: false }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`createDeployKey: ${resp.status} ${text}`);
}
const json = await resp.json() as { id: number };
return json.id;
}
/// Delete a deploy key by numeric ID. Ignores 404 (already gone).
async deleteDeployKey(keyId: number): Promise<void> {
const resp = await fetch(`${this.keysUrl}/${keyId}`, {
method: 'DELETE',
headers: this.headers,
});
if (!resp.ok && resp.status !== 404) {
const text = await resp.text();
throw new Error(`deleteDeployKey: ${resp.status} ${text}`);
}
}
} }

View File

@@ -359,17 +359,15 @@ export async function handle(
case 'register_this_device': { case 'register_this_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const keypair = state.wasm.generate_device_keypair() as { // register_device keeps private keys internal — only public keys cross to JS
public_key_hex: string; const keys = state.wasm.register_device(msg.name) as {
private_key_base64: string; signing_public_key: string;
deploy_public_key: string;
}; };
await chrome.storage.local.set({ await chrome.storage.local.set({ device_name: msg.name });
device_name: msg.name,
device_private_key: keypair.private_key_base64,
});
await devices.addDevice(state.gitHost, { await devices.addDevice(state.gitHost, {
name: msg.name, name: msg.name,
public_key: keypair.public_key_hex, public_key: keys.signing_public_key,
added_at: Math.floor(Date.now() / 1000), added_at: Math.floor(Date.now() / 1000),
}); });
return { ok: true }; return { ok: true };
@@ -377,7 +375,9 @@ export async function handle(
case 'revoke_device': { case 'revoke_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
await devices.revokeDevice(state.gitHost, msg.name); const stored = await chrome.storage.local.get(['device_name']);
const revokedBy = stored.device_name as string | undefined;
await devices.revokeDevice(state.gitHost, msg.name, revokedBy);
return { ok: true }; return { ok: true };
} }