Rotate administrator keys with safe re-enable and exact retry
This commit is contained in:
1 parent
9cf8249398
commit
55b47ce039
20 files changed
+3072
-104
No files matched your search
@@ -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,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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user