Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4028,7 +4028,7 @@
],
"parameters": [
{
"name": "id",
"name": "keyId",
"in": "query",
"required": true,
"description": "Key id.",
Expand Down Expand Up @@ -4066,7 +4066,51 @@
"post": {
"operationId": "postKeysRevoke",
"summary": "Revoke a key, keeping the record.",
"description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `id` — Key id.",
"description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `keyId` — Key id.",
"tags": [
"keys"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"responses": {
"200": {
"description": "Success."
},
"400": {
"description": "Malformed request, or a missing confirmation."
},
"401": {
"description": "No usable credential."
},
"403": {
"description": "Authenticated, but not permitted — the body names what was needed."
},
"404": {
"description": "No such server, or no such route."
},
"409": {
"description": "Conflicts with the current state (running server, name taken, …)."
},
"429": {
"description": "Rate limited. `Retry-After` says for how long."
}
}
}
},
"/api/v1/keys/disabled": {
"post": {
"operationId": "postKeysDisabled",
"summary": "Switch a key off or back on. Unlike revoking, this is reversible.",
"description": "Owner **session** only — no API key can hold a role, so no key reaches this.\n\nBody fields:\n- `keyId` — Key id.\n- `disabled` — true to switch off, false to switch back on.\n\nA revoked key cannot be switched back on; that answer is 409.",
"tags": [
"keys"
],
Expand Down
10 changes: 10 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,16 @@ export function registerIpc(): void {
})
return created
})
H(IPC.apiKeyDisabled, (_e, keyId: string, disabled: boolean) => {
const k = apikeys.setKeyDisabled(keyId, disabled)
audit.record({
source: 'panel',
action: disabled ? 'apikey.disable' : 'apikey.enable',
actor: 'operator',
target: k.label
})
return k
})
H(IPC.apiKeyRevoke, (_e, keyId: string) => {
const k = apikeys.revokeKey(keyId)
audit.record({ source: 'panel', action: 'apikey.revoke', actor: 'operator', target: k.label })
Expand Down
97 changes: 97 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { CRATE_CSS } from '@shared/crateUi'
import { openApiDocument } from '@shared/openapi'
import { clampGrace, deliveryDecision, queueReason, HOLD_REASONS } from '@shared/delivery'
import { API_PREFIX, API_ROUTES } from '@shared/apiSurface'
import { usageSamples, API_KEY_HEADER } from '@shared/apiUsage'
import { MODERATION_ACTIONS, WORLD_ACTIONS } from '@shared/ops'
import { removeServer } from './core/serverRegistry'
import * as sf from './core/serverFiles'
Expand Down Expand Up @@ -6724,6 +6725,102 @@ export async function runWebSmoke(): Promise<void> {
if (r.status !== 403) return fail('approving without settings expected 403, got ' + r.status)
}

// ---- a key can be switched off and back on (#EK) ----
{
const k2 = apikeys.createKey({ label: 'smoke_toggle', scopes: ['view'], servers: 'all' })
const probe = '/api/servers/' + id
if ((await kget(probe, k2.secret)).status !== 200) return fail('a fresh key could not read')

// Drive the call from the route table rather than from what the
// handler happens to read. Those two disagreed until #142 — the doc
// said `id`, the server read `keyId` — and an integrator who followed
// the doc exactly got a 404 from revoke and a 200 from a delete that
// deleted nothing. A test that spells the field itself would have
// stayed green through all of it.
const doc = API_ROUTES.find((rt) => rt.path === '/keys/disabled' && rt.method === 'POST')
const idField = Object.keys(doc?.body ?? {}).find((f) => f !== 'disabled')
if (!idField) return fail('the disable route documents no key field')

// Owner session only. A key must never be able to switch keys off:
// that is the same escalation that keeps key minting off the API.
const byKey = await kpost('/api/keys/disabled', { [idField]: k2.key.id, disabled: true }, superKey.secret)
if (byKey.status !== 403) return fail('an API key could disable a key: ' + byKey.status)

const viaHttp = await post('/api/keys/disabled', { [idField]: k2.key.id, disabled: true }, ot)
if (viaHttp.status !== 200) {
return fail('owner disable over HTTP: ' + viaHttp.status + ' ' + (await viaHttp.text()))
}
if (!((await viaHttp.json()) as { disabled?: boolean }).disabled) {
return fail('the route did not report it off')
}

// Disabling is checked by `isKeyUsable`, which is the single answer to
// "may this key be used" — a switch honoured in some places and not
// others is worse than no switch.
const off = await kget(probe, k2.secret)
if (off.status !== 401) return fail('a disabled key still worked: ' + off.status)

// ...and reversible, unlike revoke. That is the whole reason it is a
// separate flag: pausing an integration must not require destroying
// its credential.
apikeys.setKeyDisabled(k2.key.id, false)
if ((await kget(probe, k2.secret)).status !== 200) return fail('a re-enabled key did not work')

// A revoked key cannot be quietly resurrected by the reversible one —
// over HTTP too, where the answer is 409 rather than a thrown string.
apikeys.revokeKey(k2.key.id)
let threw = false
try {
apikeys.setKeyDisabled(k2.key.id, false)
} catch {
threw = true
}
if (!threw) return fail('enabling resurrected a revoked key')
const undead = await post('/api/keys/disabled', { [idField]: k2.key.id, disabled: false }, ot)
if (undead.status !== 409) return fail('reviving a revoked key over HTTP: ' + undead.status)
if ((await kget(probe, k2.secret)).status !== 401) return fail('a revoked key still worked')
apikeys.deleteKey(k2.key.id)

// The usage samples are what an operator follows to make a first
// request, so they have to name the header the server actually reads
// and carry no fake secret.
const samples = usageSamples({ baseUrl: 'http://127.0.0.1:8080' })
if (samples.length < 3) return fail('too few usage samples')
for (const s of samples) {
if (!s.code.includes(API_KEY_HEADER)) return fail(s.lang + ' does not send the key header')
// Not `API_PREFIX + '/servers'`: two of the three samples put the
// prefix in a BASE constant and append the path at the call site.
if (!s.code.includes(API_PREFIX)) return fail(s.lang + ' does not use the versioned prefix')
if (!s.code.includes('/servers')) return fail(s.lang + ' does not call a real route')
if (!s.code.includes('PASTE_YOUR_KEY_HERE')) {
return fail(s.lang + ' has something that looks like a real key in it')
}
}

// The panel serves this function as source, via `.toString()`. Reading
// the source for a forbidden identifier would not catch the bug: the
// bundler *renames* module bindings, so the dead reference is not
// called `API_PREFIX` by the time it reaches the page. Run it the way
// the page does instead — with no scope around it at all — which is
// the only thing that turns the ReferenceError into a failure here.
let detached: typeof usageSamples
try {
detached = new Function('return (' + usageSamples.toString() + ')')() as typeof usageSamples
} catch (e) {
return fail('usageSamples could not even be re-parsed: ' + String(e))
}
try {
const outside = detached({ baseUrl: 'http://127.0.0.1:8080' })
if (JSON.stringify(outside) !== JSON.stringify(samples)) {
return fail('usageSamples gives the page a different answer than the app')
}
} catch (e) {
return fail('usageSamples leans on module scope it will not have in the page: ' + String(e))
}
const withKey = usageSamples({ baseUrl: 'http://x', key: 'msms_abc.def' })
if (!withKey[0].code.includes('msms_abc.def')) return fail('a supplied key did not reach the sample')
}

// ---- the bridge plugin (#103) ----
{
const bBase = '/api/servers/' + id + '/bridge'
Expand Down
18 changes: 18 additions & 0 deletions src/main/web/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const view = (k: StoredKey): ApiKeyView => ({
lastUsedAt: k.lastUsedAt,
expiresAt: k.expiresAt,
revoked: k.revoked,
disabled: k.disabled,
canAudit: k.canAudit
})

Expand Down Expand Up @@ -132,6 +133,23 @@ export function revokeKey(id: string): ApiKeyView {
return view(k)
}

/**
* Switch a key off, or back on.
*
* Reversible, unlike `revokeKey`. A revoked key stays revoked — that is the
* point of revocation — so this refuses rather than quietly resurrecting one.
*/
export function setKeyDisabled(id: string, disabled: boolean): ApiKeyView {
load()
const k = keys.find((x) => x.id === id)
if (!k) throw new Error('key-not-found')
if (k.revoked) throw new Error('key-revoked')
if (disabled) k.disabled = true
else delete k.disabled
save()
return view(k)
}

export function deleteKey(id: string): void {
load()
keys = keys.filter((x) => x.id !== id)
Expand Down
38 changes: 36 additions & 2 deletions src/main/web/panelHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi'
import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi'
import { avatarUrl } from '@shared/profile'
import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons'
import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage'
import { API_PREFIX } from '@shared/apiSurface'
import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi'
export function getPanelHtml(): string {
return `<!doctype html><html lang="en"><head>
Expand Down Expand Up @@ -894,6 +896,12 @@ var avatarUrl=${avatarUrl.toString()};
in all three surfaces (#136). Self-contained, like every embedded helper. */
/* Named exactly as the shared module names it: iconSvg is embedded by
stringifying it and reads the table through this identifier. */
/* Self-contained by construction: it reads API_PREFIX through a name the page
defines, and calls nothing. See #116. */
var API_PREFIX=${JSON.stringify(API_PREFIX)};
var API_KEY_HEADER=${JSON.stringify(API_KEY_HEADER)};
var API_USAGE_NOTES=${JSON.stringify(USAGE_NOTES)};
var apiUsageSamples=${usageSamples.toString()};
var STRUCTURE_ICONS=${JSON.stringify(STRUCTURE_ICONS)};
var MAP_ICONS=STRUCTURE_ICONS;
function mapIconFor(kind){return STRUCTURE_ICONS[kind]||STRUCTURE_ICONS.other}
Expand Down Expand Up @@ -1142,7 +1150,10 @@ function loadKeys(){renderKeyScopes();
if(r.status===403){el.innerHTML='<div class="card dim">Owner access required.</div>';return}
if(!r.ok){el.innerHTML='<div class="card dim">Could not load API keys.</div>';return}
renderKeys(r.body.keys||[])})}
function keyState(k){if(k.revoked)return 'revoked';if(k.expiresAt&&k.expiresAt<=Date.now())return 'expired';return 'active'}
/* Disabled is its own state, not a kind of revoked: one is reversible and the
other is what you do to a key you think has leaked (#EK). */
function keyState(k){if(k.revoked)return 'revoked';if(k.disabled)return 'disabled';
if(k.expiresAt&&k.expiresAt<=Date.now())return 'expired';return 'active'}
function renderKeys(keys){var el=document.getElementById('keysList');
if(!keys.length){el.innerHTML='<div class="card dim">No API keys yet.</div>';return}
el.innerHTML='<div class="card">'+keys.map(function(k){var st=keyState(k);
Expand All @@ -1152,8 +1163,31 @@ function renderKeys(keys){var el=document.getElementById('keysList');
'<div class="dim" style="font-size:12px">'+esc((k.scopes||[]).join(', ')||'no permissions')+' · '+esc(where)+
(k.expiresAt?' · expires '+new Date(k.expiresAt).toLocaleDateString():'')+
(k.lastUsedAt?' · last used '+new Date(k.lastUsedAt).toLocaleString():' · never used')+'</div></div>'+
'<button class="btn sm" onclick="showKeyUsage(\\''+k.id+'\\')">How to use</button>'+
(k.revoked?'':'<button class="btn sm" onclick="toggleKey(\\''+k.id+'\\','+(k.disabled?'false':'true')+')">'+
(k.disabled?'Enable':'Disable')+'</button>')+
(k.revoked?'':'<button class="btn sm" onclick="revokeKey(\\''+k.id+'\\')">Revoke</button>')+
'<button class="btn sm danger" onclick="deleteKey(\\''+k.id+'\\')">🗑</button></div>'}).join('')+'</div>'}
'<button class="btn sm danger" onclick="deleteKey(\\''+k.id+'\\')">🗑</button></div>'+
'<div id="ku_'+k.id+'" class="hidden" style="padding:0 0 10px 34px"></div>'}).join('')+'</div>'}
/* Disable is reversible; revoke is not. Two buttons because they are two
different decisions. */
function toggleKey(id,off){
api('/api/keys/disabled',{method:'POST',body:JSON.stringify({keyId:id,disabled:off})}).then(function(r){
if(!r.ok){alert(r.body&&r.body.error==='key-revoked'?'That key is revoked — issue a new one.':'Could not change the key.');return}
loadKeys()})}
/* The samples an operator needs to make a first request. The real secret is
gone after creation, so these carry a clearly marked placeholder rather than
something that looks like a key and is not. */
function showKeyUsage(id){
var box=document.getElementById('ku_'+id);if(!box)return;
if(!box.classList.contains('hidden')){box.classList.add('hidden');return}
box.classList.remove('hidden');
var s=apiUsageSamples({baseUrl:location.origin});
box.innerHTML='<div class="dim" style="font-size:12px;margin-bottom:6px">'+
API_USAGE_NOTES.map(function(n){return esc(n)}).join('<br/>')+'</div>'+
s.map(function(x){return '<div style="margin-top:8px"><b style="font-size:12px">'+esc(x.lang)+'</b>'+
'<pre style="white-space:pre-wrap;word-break:break-word;font-size:11.5px;background:var(--elev);'+
'padding:9px;border-radius:8px;margin:4px 0 0">'+esc(x.code)+'</pre></div>'}).join('')}
function createKey(){var label=document.getElementById('kLabel').value.trim();if(!label){alert('Give the key a label.');return}
var scopes=KEY_SCOPES.filter(function(s){return kScopeSel[s]});
var all=document.getElementById('kAll').checked;
Expand Down
20 changes: 20 additions & 0 deletions src/main/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,26 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise<v
return sendJson(res, 404, { error: 'key-not-found' })
}
}
// Reversible, unlike revoke. Pausing an integration and destroying a leaked
// credential are different actions and must not share a button.
if (path === '/api/keys/disabled' && method === 'POST') {
const b = (await readBody(req).catch(() => ({}))) as { keyId?: string; disabled?: boolean }
try {
const k = apikeys.setKeyDisabled(b.keyId ?? '', !!b.disabled)
audit.record({
source: 'webpanel',
action: b.disabled ? 'apikey.disable' : 'apikey.enable',
actor: user.username,
target: k.label,
ok: true,
ip
})
return sendJson(res, 200, k)
} catch (e) {
const why = String(e).includes('revoked') ? 'key-revoked' : 'key-not-found'
return sendJson(res, why === 'key-revoked' ? 409 : 404, { error: why })
}
}
if (path === '/api/keys' && method === 'DELETE') {
const keyId = url.searchParams.get('keyId') ?? ''
apikeys.deleteKey(keyId)
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const api: MsmsApi = {
installBridge: (id) => ipcRenderer.invoke(IPC.bridgeInstall, id),
mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks),
clearMapCache: () => ipcRenderer.invoke(IPC.mapCacheClear),
setApiKeyDisabled: (id, disabled) => ipcRenderer.invoke(IPC.apiKeyDisabled, id, disabled),

listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh),
resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override),
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,10 @@ export default {
keySecretWarn: 'Copy this now. It is stored hashed and will never be shown again.',
keyCopy: 'Copy',
keyCopied: 'Key copied',
keyDisabled: 'disabled',
keyDisable: 'Switch this key off (reversible)',
keyEnable: 'Switch this key back on',
keyHowTo: 'How to use this key',
keyRevoke: 'Revoke',
keyRevoked: 'revoked',
keyActive: 'active',
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,10 @@ const tr: typeof en = {
'Şimdi kopyala. Anahtar özetlenerek saklanır ve bir daha asla gösterilmez.',
keyCopy: 'Kopyala',
keyCopied: 'Anahtar kopyalandı',
keyDisabled: 'devre dışı',
keyDisable: 'Bu anahtarı kapat (geri alınabilir)',
keyEnable: 'Bu anahtarı tekrar aç',
keyHowTo: 'Bu anahtar nasıl kullanılır',
keyRevoke: 'İptal et',
keyRevoked: 'iptal edildi',
keyActive: 'etkin',
Expand Down
Loading
Loading