Rotate administrator keys with safe re-enable and exact retry

This commit is contained in:
Quintine committed 2026-09-30 00:01:16 +10:00
1 parent 9cf8249398
commit 55b47ce039
20 files changed
+3072 -104

No files matched your search

+29 -11
View File
@@ -1,9 +1,22 @@
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow } from './admin-accounts';
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow, type AdminAccountsDirectory } from './admin-accounts';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
export type AdminAccountRecord =
| { kind: 'pending'; input: AdminAccountInput }
| { kind: 'completed'; input: Extract<AdminAccountInput, { action: 'provision' }>; account: AdminAccountRow };
| { kind: 'completed'; input: Extract<AdminAccountInput, { action: 'provision' | 'rotate' }>; account: AdminAccountRow };
/** A receipt describes a past write. The directory can only confirm whether
* its account metadata still matched at the most recent read, not future key validity. */
export function adminAccountKeyStatus(
record: Extract<AdminAccountRecord, { kind: 'completed' }>,
directory: AdminAccountsDirectory | null,
): 'matching' | 'changed' | 'unverified' {
const live = directory?.accounts.find(row => row.id === record.account.id);
if (!live) return 'unverified';
return live.enabled && live.updatedAt === record.account.updatedAt &&
live.createdAt === record.account.createdAt && live.name === record.account.name &&
live.role === record.account.role ? 'matching' : 'changed';
}
const storageKey = (owner: string) => `dune.admin-accounts.v1:${owner}`;
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -36,20 +49,24 @@ export function validAdminAccountResult(value: unknown, input: AdminAccountInput
if (input.action === 'provision')
return account.enabled && account.name === input.name && account.role === input.role;
return account.updatedAt > input.expectedUpdatedAt &&
(input.action === 'role' ? account.enabled && account.role === input.role : !account.enabled);
(input.action === 'role' ? account.enabled && account.role === input.role :
input.action === 'rotate' ? account.enabled : !account.enabled);
}
const newSecret = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
export function newAdminAccountRequest(fields:
| { action: 'provision'; name: string; role: AdminAccountRow['role']; reason: string }
| { action: 'role'; target: string; expectedUpdatedAt: number; role: AdminAccountRow['role']; reason: string }
| { action: 'disable'; target: string; expectedUpdatedAt: number; reason: string },
| { action: 'disable' | 'rotate'; target: string; expectedUpdatedAt: number; reason: string },
): AdminAccountInput {
const operationId = crypto.randomUUID();
const input = fields.action === 'provision' ? (() => {
const id = crypto.randomUUID();
const secret = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${secret}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() };
})() : { ...fields, operationId, reason: fields.reason.trim() };
return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${newSecret()}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() };
})() : fields.action === 'rotate'
? { ...fields, operationId, key: `dune-admin.${fields.target}.${newSecret()}`, reason: fields.reason.trim() }
: { ...fields, operationId, reason: fields.reason.trim() };
if (!validAdminAccountInput(input)) throw new Error('Check the account, role and operational reason. Do not include credentials in the reason.');
return input;
}
@@ -63,10 +80,10 @@ export function readAdminAccountRecord(storage: Storage, owner: string): AdminAc
const record = value as Record<string, unknown>;
if (Object.keys(record).length === 2 && record.kind === 'pending' && validAdminAccountInput(record.input)) return value as AdminAccountRecord;
if (Object.keys(record).length === 3 && record.kind === 'completed' && validAdminAccountInput(record.input) &&
record.input.action === 'provision' && validAdminAccountRow(record.account)) {
(record.input.action === 'provision' || record.input.action === 'rotate') && validAdminAccountRow(record.account)) {
const input = record.input, account = record.account;
if (account.id === input.id && account.name === input.name &&
account.role === input.role && account.enabled) return value as AdminAccountRecord;
if (validAdminAccountResult({ operationId: input.operationId, replayed: false, account }, input))
return value as AdminAccountRecord;
}
}
throw new Error('This tab’s saved account request is unreadable. Do not send a new request or discard this record until you have checked the account directory.');
@@ -91,7 +108,8 @@ export function completeAdminAccountRequest(storage: Storage, owner: string, inp
const previous = readAdminAccountRecord(storage, owner);
if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input))
throw new Error('The saved account request changed. Keep it and inspect the directory.');
if (input.action === 'provision') persist(storage, owner, { kind: 'completed', input, account: result.account });
if (input.action === 'provision' || input.action === 'rotate')
persist(storage, owner, { kind: 'completed', input, account: result.account });
else clearAdminAccountRecord(storage, owner);
}
+7 -1
View File
@@ -7,7 +7,8 @@ export type AdminAccountsDirectory = { accounts: AdminAccountRow[]; total: numbe
export type AdminAccountInput =
| { action: 'provision'; operationId: string; id: string; key: string; name: string; role: AdminRole; reason: string }
| { action: 'role'; operationId: string; target: string; expectedUpdatedAt: number; role: AdminRole; reason: string }
| { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string };
| { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string }
| { action: 'rotate'; operationId: string; target: string; expectedUpdatedAt: number; key: string; reason: string };
export type AdminAccountResult = { operationId: string; replayed: boolean; account: AdminAccountRow };
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -40,5 +41,10 @@ export function validAdminAccountInput(value: unknown): value is AdminAccountInp
if (input.action === 'disable')
return exact(input, ['action','operationId','target','expectedUpdatedAt','reason']) &&
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt);
if (input.action === 'rotate')
return exact(input, ['action','operationId','target','expectedUpdatedAt','key','reason']) &&
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt) &&
typeof input.key === 'string' && input.key === `dune-admin.${input.target}.${input.key.slice(-64)}` &&
/^dune-admin\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.[0-9a-f]{64}$/.test(input.key);
return false;
}
+2 -2
View File
@@ -4,14 +4,14 @@ export const AUDIT_CATEGORIES = {
} as const;
export type AuditCategory = keyof typeof AUDIT_CATEGORIES;
export const AUDIT_ACTIONS = {
provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', rotate: 'Rotate administrator key', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
room_control: 'Apply room controls', create: 'Create room', rules: 'Change rules', techTokens: 'Change Tech Tokens', strongholdCards: 'Change Stronghold Cards',
addBot: 'Add AI player', removeBot: 'Remove AI player', assignHost: 'Assign host', configureBot: 'Configure AI player',
remove: 'Remove room', restore: 'Restore room', close: 'Close room', reopen: 'Reopen room', archive: 'Archive room', unarchive: 'Unarchive room',
participant_ai: 'Enable participant AI', mute: 'Mute discussion', unmute: 'Unmute discussion', unknown: 'Unrecognized recorded action',
} as const;
export type AuditAction = keyof typeof AUDIT_ACTIONS;
export const AUDIT_FIELDS = ['Role','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const;
export const AUDIT_FIELDS = ['Role','Enabled','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const;
export type AuditChange = { field: typeof AUDIT_FIELDS[number]; before: string | null; after: string | null };
export type AdminAuditEvent = {
id: string; category: AuditCategory; action: AuditAction; createdAt: number | null;
+3 -2
View File
@@ -22,9 +22,10 @@ export async function adminBody(request: Request, configuredOrigin?: string): Pr
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 2048) { void reader.cancel(); throw new AdminError('Request too large.', 413); }
chunks.push(value);
// Drain the request without retaining bytes beyond the limit.
if (size <= 2048) chunks.push(value);
}
if (size > 2048) throw new AdminError('Request too large.', 413);
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }