From 520f6ec72c4a00cb2e8d464eaee89f4efd62f16b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 12:27:14 -0400 Subject: [PATCH] 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 --- extension/src/service-worker/devices.ts | 47 +++++++++++++++++-- extension/src/service-worker/gitea.ts | 29 ++++++++++++ .../src/service-worker/router/popup-only.ts | 18 +++---- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/extension/src/service-worker/devices.ts b/extension/src/service-worker/devices.ts index 1c0ac59..b35b3be 100644 --- a/extension/src/service-worker/devices.ts +++ b/extension/src/service-worker/devices.ts @@ -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 { Device } from '../shared/types'; const DEVICES_PATH = '.relicario/devices.json'; +const REVOKED_PATH = '.relicario/revoked.json'; interface DevicesFile { 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 { try { const raw = await gitHost.readFile(DEVICES_PATH); @@ -30,6 +38,25 @@ export async function writeDevices( await gitHost.writeFile(DEVICES_PATH, bytes, message); } +export async function readRevoked(gitHost: GitHost): Promise { + 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 { + const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2)); + await gitHost.writeFile(REVOKED_PATH, bytes, message); +} + export async function addDevice( gitHost: GitHost, device: Device, @@ -45,11 +72,25 @@ export async function addDevice( export async function revokeDevice( gitHost: GitHost, name: string, + revokedBy?: string, ): Promise { const existing = await readDevices(gitHost); - const filtered = existing.filter((d) => d.name !== name); - if (filtered.length === existing.length) { + const device = existing.find((d) => d.name === name); + if (!device) { 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}`); + + // 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)`); } diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index 08abf22..e888bdd 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -17,6 +17,7 @@ export class GiteaHost implements GitHost { private baseUrl: string; private gitApiBase: string; private commitsUrl: string; + private keysUrl: string; private branch: string = 'main'; private headers: Record; @@ -27,6 +28,7 @@ export class GiteaHost implements GitHost { this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`; this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`; this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`; + this.keysUrl = `${apiUrl}/repos/${repoPath}/keys`; this.headers = { 'Authorization': `token ${apiToken}`, 'Content-Type': 'application/json', @@ -244,4 +246,31 @@ export class GiteaHost implements GitHost { async deleteBlob(path: string, message: string): Promise { return this.deleteFile(path, message); } + + /// Create a deploy key for this repo, returning its numeric ID. + async createDeployKey(title: string, publicKey: string): Promise { + 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 { + 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}`); + } + } } diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 841b195..5fa9367 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -359,17 +359,15 @@ export async function handle( case 'register_this_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; - const keypair = state.wasm.generate_device_keypair() as { - public_key_hex: string; - private_key_base64: string; + // register_device keeps private keys internal — only public keys cross to JS + const keys = state.wasm.register_device(msg.name) as { + signing_public_key: string; + deploy_public_key: string; }; - await chrome.storage.local.set({ - device_name: msg.name, - device_private_key: keypair.private_key_base64, - }); + await chrome.storage.local.set({ device_name: msg.name }); await devices.addDevice(state.gitHost, { name: msg.name, - public_key: keypair.public_key_hex, + public_key: keys.signing_public_key, added_at: Math.floor(Date.now() / 1000), }); return { ok: true }; @@ -377,7 +375,9 @@ export async function handle( case 'revoke_device': { 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 }; }