299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
;(() => {
|
|
if (!globalThis.navigator?.credentials?.create) {
|
|
throw new Error('navigator.credentials.create is not available on this page')
|
|
}
|
|
|
|
if (navigator.credentials.__sbiClientMockCreateInstalled) {
|
|
console.warn('[sbi-client] mock navigator.credentials.create is already installed')
|
|
return
|
|
}
|
|
|
|
const originalCreate = navigator.credentials.create.bind(navigator.credentials)
|
|
const textEncoder = new TextEncoder()
|
|
const credentialLabel = 'mnie'
|
|
|
|
const base64Url = (value) => {
|
|
const bytes =
|
|
value instanceof ArrayBuffer
|
|
? new Uint8Array(value)
|
|
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
let binary = ''
|
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
}
|
|
|
|
const fromBase64Url = (value) => {
|
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='))
|
|
const bytes = new Uint8Array(binary.length)
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
return bytes
|
|
}
|
|
|
|
const toBytes = (value) => {
|
|
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
if (ArrayBuffer.isView(value))
|
|
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
if (Array.isArray(value)) return new Uint8Array(value)
|
|
if (typeof value === 'string') return fromBase64Url(value)
|
|
throw new Error('unsupported binary value')
|
|
}
|
|
|
|
const concat = (...parts) => {
|
|
const length = parts.reduce((sum, part) => sum + part.length, 0)
|
|
const out = new Uint8Array(length)
|
|
let offset = 0
|
|
for (const part of parts) {
|
|
out.set(part, offset)
|
|
offset += part.length
|
|
}
|
|
return out
|
|
}
|
|
|
|
const sha256 = async (value) => new Uint8Array(await crypto.subtle.digest('SHA-256', value))
|
|
|
|
const randomBytes = (length) => {
|
|
const bytes = new Uint8Array(length)
|
|
crypto.getRandomValues(bytes)
|
|
return bytes
|
|
}
|
|
|
|
const cborUInt = (major, value) => {
|
|
if (value < 24) return Uint8Array.of((major << 5) | value)
|
|
if (value < 0x100) return Uint8Array.of((major << 5) | 24, value)
|
|
if (value < 0x10000) return Uint8Array.of((major << 5) | 25, value >> 8, value & 0xff)
|
|
return Uint8Array.of(
|
|
(major << 5) | 26,
|
|
(value >>> 24) & 0xff,
|
|
(value >>> 16) & 0xff,
|
|
(value >>> 8) & 0xff,
|
|
value & 0xff,
|
|
)
|
|
}
|
|
|
|
const cborEncode = (value) => {
|
|
if (value === null) return Uint8Array.of(0xf6)
|
|
if (value === false) return Uint8Array.of(0xf4)
|
|
if (value === true) return Uint8Array.of(0xf5)
|
|
if (typeof value === 'number') {
|
|
if (!Number.isInteger(value)) throw new Error('CBOR encoder only supports integers')
|
|
return value >= 0 ? cborUInt(0, value) : cborUInt(1, -1 - value)
|
|
}
|
|
if (typeof value === 'string') {
|
|
const bytes = textEncoder.encode(value)
|
|
return concat(cborUInt(3, bytes.length), bytes)
|
|
}
|
|
if (value instanceof Uint8Array) return concat(cborUInt(2, value.length), value)
|
|
if (Array.isArray(value)) return concat(cborUInt(4, value.length), ...value.map(cborEncode))
|
|
if (value && typeof value === 'object') {
|
|
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value)
|
|
const encoded = entries.flatMap(([key, item]) => [cborEncode(key), cborEncode(item)])
|
|
return concat(cborUInt(5, entries.length), ...encoded)
|
|
}
|
|
throw new Error('unsupported CBOR value')
|
|
}
|
|
|
|
const derToRawP256 = (spki) => {
|
|
const bytes = new Uint8Array(spki)
|
|
const marker = Uint8Array.of(0x03, 0x42, 0x00, 0x04)
|
|
for (let i = 0; i <= bytes.length - marker.length - 64; i++) {
|
|
if (marker.every((byte, index) => bytes[i + index] === byte)) {
|
|
return bytes.slice(i + marker.length, i + marker.length + 64)
|
|
}
|
|
}
|
|
throw new Error('could not extract P-256 public key from SPKI')
|
|
}
|
|
|
|
const selectAlgorithm = (params = []) => {
|
|
if (params.some((param) => param.type === 'public-key' && param.alg === -7)) return -7
|
|
throw new Error('mock-create currently supports ES256 only')
|
|
}
|
|
|
|
const makeCredential = async (publicKey) => {
|
|
const alg = selectAlgorithm(publicKey.pubKeyCredParams)
|
|
const rpId = publicKey.rp?.id || location.hostname
|
|
const origin = location.origin
|
|
const challenge = toBytes(publicKey.challenge)
|
|
const credentialId = randomBytes(32)
|
|
const userHandle = publicKey.user?.id ? toBytes(publicKey.user.id) : undefined
|
|
|
|
const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
|
'sign',
|
|
'verify',
|
|
])
|
|
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey)
|
|
const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
|
|
const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey)
|
|
const rawPublicKey = derToRawP256(spki)
|
|
const cosePublicKey = cborEncode(
|
|
new Map([
|
|
[1, 2],
|
|
[3, alg],
|
|
[-1, 1],
|
|
[-2, rawPublicKey.slice(0, 32)],
|
|
[-3, rawPublicKey.slice(32, 64)],
|
|
]),
|
|
)
|
|
|
|
const clientDataJSON = textEncoder.encode(
|
|
JSON.stringify({
|
|
type: 'webauthn.create',
|
|
challenge: base64Url(challenge),
|
|
origin,
|
|
crossOrigin: false,
|
|
}),
|
|
)
|
|
|
|
const rpIdHash = await sha256(textEncoder.encode(rpId))
|
|
const flags = Uint8Array.of(0x45)
|
|
const signCount = Uint8Array.of(0, 0, 0, 0)
|
|
const aaguid = new Uint8Array(16)
|
|
const credentialIdLength = Uint8Array.of(
|
|
(credentialId.length >> 8) & 0xff,
|
|
credentialId.length & 0xff,
|
|
)
|
|
const authenticatorData = concat(
|
|
rpIdHash,
|
|
flags,
|
|
signCount,
|
|
aaguid,
|
|
credentialIdLength,
|
|
credentialId,
|
|
cosePublicKey,
|
|
)
|
|
const attestationObject = cborEncode(
|
|
new Map([
|
|
['fmt', 'none'],
|
|
['attStmt', new Map()],
|
|
['authData', authenticatorData],
|
|
]),
|
|
)
|
|
|
|
const now = new Date().toISOString()
|
|
const vaultObject = {
|
|
version: 1,
|
|
kind: 'webauthn-credential',
|
|
provider: 'sbi-sec',
|
|
label: credentialLabel,
|
|
rpId,
|
|
origin,
|
|
credentialId: base64Url(credentialId),
|
|
userHandle: userHandle ? base64Url(userHandle) : undefined,
|
|
alg,
|
|
publicKey: {
|
|
format: 'jwk',
|
|
jwk: publicJwk,
|
|
},
|
|
authenticator: {
|
|
aaguid: '00000000-0000-0000-0000-000000000000',
|
|
signCount: 0,
|
|
discoverable:
|
|
publicKey.residentKey === 'required' ||
|
|
publicKey.authenticatorSelection?.residentKey === 'required',
|
|
userVerification:
|
|
publicKey.userVerification ||
|
|
publicKey.authenticatorSelection?.userVerification ||
|
|
'preferred',
|
|
transports: ['internal'],
|
|
backupEligible: false,
|
|
backupState: false,
|
|
},
|
|
secretPlaintext: {
|
|
privateKey: {
|
|
format: 'jwk',
|
|
jwk: privateJwk,
|
|
},
|
|
registration: {
|
|
attestationObject: base64Url(attestationObject),
|
|
clientDataJSON: base64Url(clientDataJSON),
|
|
},
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
const credential = {
|
|
id: base64Url(credentialId),
|
|
rawId: credentialId.buffer.slice(
|
|
credentialId.byteOffset,
|
|
credentialId.byteOffset + credentialId.byteLength,
|
|
),
|
|
type: 'public-key',
|
|
authenticatorAttachment: 'platform',
|
|
response: {
|
|
attestationObject: attestationObject.buffer.slice(
|
|
attestationObject.byteOffset,
|
|
attestationObject.byteOffset + attestationObject.byteLength,
|
|
),
|
|
clientDataJSON: clientDataJSON.buffer.slice(
|
|
clientDataJSON.byteOffset,
|
|
clientDataJSON.byteOffset + clientDataJSON.byteLength,
|
|
),
|
|
getAuthenticatorData: () =>
|
|
authenticatorData.buffer.slice(
|
|
authenticatorData.byteOffset,
|
|
authenticatorData.byteOffset + authenticatorData.byteLength,
|
|
),
|
|
getPublicKey: () => spki,
|
|
getPublicKeyAlgorithm: () => alg,
|
|
getTransports: () => ['internal'],
|
|
},
|
|
getClientExtensionResults: () => ({}),
|
|
toJSON: () => ({
|
|
id: base64Url(credentialId),
|
|
rawId: base64Url(credentialId),
|
|
type: 'public-key',
|
|
authenticatorAttachment: 'platform',
|
|
response: {
|
|
attestationObject: base64Url(attestationObject),
|
|
clientDataJSON: base64Url(clientDataJSON),
|
|
authenticatorData: base64Url(authenticatorData),
|
|
publicKey: base64Url(new Uint8Array(spki)),
|
|
publicKeyAlgorithm: alg,
|
|
transports: ['internal'],
|
|
},
|
|
clientExtensionResults: {},
|
|
}),
|
|
}
|
|
|
|
return { credential, vaultObject }
|
|
}
|
|
|
|
const openJsonTab = (value) => {
|
|
const json = JSON.stringify(value, null, 2)
|
|
const blob = new Blob([json], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const tab = window.open(url, '_blank', 'noopener,noreferrer')
|
|
if (!tab) {
|
|
console.warn('[sbi-client] could not open JSON tab; popup may have been blocked')
|
|
URL.revokeObjectURL(url)
|
|
return
|
|
}
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
|
}
|
|
|
|
navigator.credentials.create = async (options) => {
|
|
if (!options?.publicKey) return originalCreate(options)
|
|
|
|
const { credential, vaultObject } = await makeCredential(options.publicKey)
|
|
globalThis.__sbiClientLastCreatedCredential = vaultObject
|
|
console.log('[sbi-client] created mock WebAuthn credential')
|
|
console.log(JSON.stringify(vaultObject, null, 2))
|
|
openJsonTab(vaultObject)
|
|
return credential
|
|
}
|
|
|
|
Object.defineProperty(navigator.credentials, '__sbiClientMockCreateInstalled', {
|
|
value: true,
|
|
enumerable: false,
|
|
})
|
|
|
|
globalThis.__sbiClientRestoreCreate = () => {
|
|
navigator.credentials.create = originalCreate
|
|
delete globalThis.__sbiClientRestoreCreate
|
|
console.log('[sbi-client] restored native navigator.credentials.create')
|
|
}
|
|
|
|
console.log('[sbi-client] installed mock navigator.credentials.create')
|
|
})()
|