Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
151 lines
7.4 KiB
TypeScript
151 lines
7.4 KiB
TypeScript
import { api } from './client'
|
||
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types'
|
||
|
||
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
|
||
export async function conditionalSupported(): Promise<boolean> {
|
||
try {
|
||
return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。
|
||
* `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串);
|
||
* `external`:安全密钥,或者用手机扫码。
|
||
*/
|
||
export type PasskeyPlace = 'device' | 'external'
|
||
|
||
/** 浏览器是否支持 WebAuthn(passkey)。 */
|
||
export function passkeySupported(): boolean {
|
||
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
|
||
}
|
||
|
||
function toBytes(text: string): Uint8Array<ArrayBuffer> {
|
||
const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=')
|
||
const binary = atob(padded)
|
||
const out = new Uint8Array(binary.length)
|
||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
|
||
return out
|
||
}
|
||
|
||
function toText(buffer: ArrayBuffer): string {
|
||
let binary = ''
|
||
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
|
||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||
}
|
||
|
||
/**
|
||
* 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。
|
||
* passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。
|
||
*/
|
||
export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise<LoginResponse> {
|
||
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/passkey/options')
|
||
const credential = (await navigator.credentials.get({
|
||
// conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的)
|
||
mediation: opts.conditional ? 'conditional' : undefined,
|
||
signal: opts.signal,
|
||
publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' },
|
||
})) as PublicKeyCredential | null
|
||
if (!credential) throw new Error('没有选择 passkey')
|
||
const response = credential.response as AuthenticatorAssertionResponse
|
||
return api.post<LoginResponse>('/api/admin/auth/passkey/login', {
|
||
credential_id: credential.id,
|
||
authenticator_data: toText(response.authenticatorData),
|
||
client_data_json: toText(response.clientDataJSON),
|
||
signature: toText(response.signature),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 用 passkey 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。
|
||
* 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。
|
||
*/
|
||
export async function signStatement(statement: string): Promise<SignedStatementDto> {
|
||
const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement)))
|
||
let credential: PublicKeyCredential | null
|
||
try {
|
||
credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null
|
||
} catch (e) {
|
||
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消')
|
||
throw e
|
||
}
|
||
if (!credential) throw new Error('没有选择 passkey')
|
||
const response = credential.response as AuthenticatorAssertionResponse
|
||
return {
|
||
statement,
|
||
credential_id: credential.id,
|
||
authenticator_data: toText(response.authenticatorData),
|
||
client_data_json: toText(response.clientDataJSON),
|
||
signature: toText(response.signature),
|
||
}
|
||
}
|
||
|
||
/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */
|
||
export type ElevationClass = 'enroll' | 'revoke' | 'credential'
|
||
|
||
export const ELEVATED_EVENT = 'moshiro:elevated'
|
||
|
||
/**
|
||
* 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。
|
||
* 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。
|
||
*/
|
||
export async function elevate(elevation: ElevationClass): Promise<boolean> {
|
||
const options = await api.post<PasskeyRequestOptionsDto>('/api/admin/auth/elevation/options', { class: elevation })
|
||
let credential: PublicKeyCredential | null
|
||
try {
|
||
credential = (await navigator.credentials.get({
|
||
publicKey: {
|
||
challenge: toBytes(options.challenge),
|
||
rpId: options.rp_id,
|
||
timeout: options.timeout_ms,
|
||
userVerification: 'required',
|
||
allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
|
||
},
|
||
})) as PublicKeyCredential | null
|
||
} catch (e) {
|
||
if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false
|
||
throw e
|
||
}
|
||
if (!credential) return false
|
||
const response = credential.response as AuthenticatorAssertionResponse
|
||
await api.post('/api/admin/auth/elevation', {
|
||
class: elevation,
|
||
credential_id: credential.id,
|
||
authenticator_data: toText(response.authenticatorData),
|
||
client_data_json: toText(response.clientDataJSON),
|
||
signature: toText(response.signature),
|
||
})
|
||
window.dispatchEvent(new Event(ELEVATED_EVENT))
|
||
return true
|
||
}
|
||
|
||
/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */
|
||
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
|
||
const options = await api.post<PasskeyCreationOptionsDto>('/api/admin/auth/passkeys/options')
|
||
const credential = (await navigator.credentials.create({
|
||
publicKey: {
|
||
challenge: toBytes(options.challenge),
|
||
rp: { id: options.rp_id, name: options.rp_name },
|
||
user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name },
|
||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
||
excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })),
|
||
authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
|
||
attestation: 'none',
|
||
timeout: options.timeout_ms,
|
||
// 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机
|
||
hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'],
|
||
} as PublicKeyCredentialCreationOptions,
|
||
})) as PublicKeyCredential | null
|
||
if (!credential) throw new Error('没有创建 passkey')
|
||
const response = credential.response as AuthenticatorAttestationResponse
|
||
return api.post<PasskeyDto>('/api/admin/auth/passkeys', {
|
||
name,
|
||
credential_id: credential.id,
|
||
attestation_object: toText(response.attestationObject),
|
||
client_data_json: toText(response.clientDataJSON),
|
||
transports: response.getTransports?.() ?? [],
|
||
})
|
||
}
|