Skip to content

Commit 5f16634

Browse files
author
Zachary Whitley
committed
feat(plugins): webauthn-bridge + webcrypto-bridge polyfill plugins
Two new wasi-polyfill plugins that satisfy the tegmentum:webauthn- bridge and tegmentum:webcrypto-bridge WITs imported by the pkcs11-webauthn-adapter and pkcs11-webcrypto-adapter wasm components (Layer-4 alternatives to pkcs11-provider+softhsm and pkcs11-gateway- adapter+ws-gateway-server in the openssl-wasm component stack). webauthn-bridge: - list-credentials(rpId) -> reads from IndexedDB roster (browser doesn't expose a 'list my credentials' API; the polyfill caches what register() returned) - sign(credentialId, challenge) -> navigator.credentials.get with allowCredentials=[credentialId]; returns the raw signature - register(rpId, userName, label, algorithm, challenge) -> navigator.credentials.create; parses SPKI from the attestation object; persists to IndexedDB roster webcrypto-bridge: - Storage backend chosen at plugin construction (storage: 'idb' | 'memory'). IDB is structured-clone of CryptoKey objects; memory is Map<keyId, CryptoKey> cleared on plugin destroy. - sign / verify / encrypt / decrypt: one-shot crypto.subtle calls - generate-key (symmetric), generate-key-pair (asymmetric), import-key (raw/pkcs8/spki/jwk), delete-key. - 13-entry SubtleAlg enum (ecdsa-p256/p384/p521, rsassa-pkcs1v15, rsa-pss, rsa-oaep, aes-gcm, aes-kw, ecdh-p256/p384, hkdf, ed25519, x25519) — kept in sync with the WIT enum positions. Both plugins follow the existing wsGatewayPkcs11TunnelPlugin pattern: single 'browser' implementation (no other options make sense), exposed via package.json subpath @tegmentum/wasi-polyfill/plugins/webauthn-bridge and /webcrypto-bridge. The PluginInstance.getImports() shape exposes both dash-case (WIT method resolution) and bare-identifier (jco --instantiation runtime) keys. TS strictness notes carried in inline comments: Uint8Array -> ArrayBuffer slice for BufferSource compat, getImports map can't have duplicate property names so single-key entries used for methods without dashes, createPlugin requires explicit defaultImplementation 3rd arg.
1 parent abadff4 commit 5f16634

8 files changed

Lines changed: 674 additions & 0 deletions

File tree

package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@
7575
"types": "./dist/wasip2/plugins/ws-gateway/index.d.ts",
7676
"import": "./dist/wasip2/plugins/ws-gateway/index.js"
7777
},
78+
"./wasip2/plugins/webauthn-bridge": {
79+
"types": "./dist/wasip2/plugins/webauthn-bridge/index.d.ts",
80+
"import": "./dist/wasip2/plugins/webauthn-bridge/index.js"
81+
},
82+
"./wasip2/plugins/webcrypto-bridge": {
83+
"types": "./dist/wasip2/plugins/webcrypto-bridge/index.d.ts",
84+
"import": "./dist/wasip2/plugins/webcrypto-bridge/index.js"
85+
},
86+
"./plugins/webauthn-bridge": {
87+
"types": "./dist/wasip2/plugins/webauthn-bridge/index.d.ts",
88+
"import": "./dist/wasip2/plugins/webauthn-bridge/index.js"
89+
},
90+
"./plugins/webcrypto-bridge": {
91+
"types": "./dist/wasip2/plugins/webcrypto-bridge/index.d.ts",
92+
"import": "./dist/wasip2/plugins/webcrypto-bridge/index.js"
93+
},
7894
"./wasip2/plugins/logging": {
7995
"types": "./dist/wasip2/plugins/logging/index.d.ts",
8096
"import": "./dist/wasip2/plugins/logging/index.js"
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Browser-side implementation of `tegmentum:webauthn-bridge/bridge`.
3+
*
4+
* Satisfies the `pkcs11-webauthn-adapter` wasm component's import by
5+
* routing list/sign/register calls to `navigator.credentials.*` +
6+
* an IndexedDB-backed credential roster (because the WebAuthn API
7+
* has no "list my credentials" primitive — the polyfill remembers
8+
* what `register()` returned).
9+
*
10+
* jco's --instantiation=async lift wraps the async return in
11+
* `{tag:'ok', val}` on resolve and `{tag:'err', val}` on throw, so
12+
* we return the inner record directly on success and `throw {tag:...}`
13+
* for bridge-error variants.
14+
*/
15+
16+
import type { Implementation, PluginConfig, PluginInstance } from '../../core/types.js'
17+
18+
// COSE algorithm enum positions per webauthn-bridge.wit:
19+
// es256(0) | rs256(1) | eddsa(2) | ps256(3)
20+
// COSE numeric identifiers: ES256=-7, RS256=-257, EdDSA=-8, PS256=-37.
21+
const COSE_ENUM_TO_NUMBER = [-7, -257, -8, -37] as const
22+
const COSE_NUMBER_TO_ENUM: Record<number, number> = { [-7]: 0, [-257]: 1, [-8]: 2, [-37]: 3 }
23+
24+
type CoseEnum = 0 | 1 | 2 | 3
25+
26+
interface CredentialInfo {
27+
credentialId: Uint8Array
28+
rpId: string
29+
algorithm: CoseEnum
30+
publicKeySpki: Uint8Array
31+
label: string
32+
}
33+
34+
const IDB_NAME = 'webauthn-bridge-credentials-v1'
35+
const IDB_STORE = 'credentials'
36+
const IDB_VERSION = 1
37+
38+
/** Open the IndexedDB instance the polyfill uses to persist the
39+
* credential roster across page reloads. The browser doesn't expose
40+
* a "list my credentials" API — register() is the only way to learn
41+
* about a credential, so we cache what it returns. */
42+
function openDb(): Promise<IDBDatabase> {
43+
return new Promise((resolve, reject) => {
44+
const req = indexedDB.open(IDB_NAME, IDB_VERSION)
45+
req.onupgradeneeded = () => {
46+
req.result.createObjectStore(IDB_STORE, { keyPath: 'credentialIdHex' })
47+
}
48+
req.onsuccess = () => resolve(req.result)
49+
req.onerror = () => reject(req.error ?? new Error('idb open failed'))
50+
})
51+
}
52+
53+
function hex(bytes: Uint8Array): string {
54+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
55+
}
56+
57+
async function dbList(rpId: string): Promise<CredentialInfo[]> {
58+
const db = await openDb()
59+
return new Promise((resolve, reject) => {
60+
const tx = db.transaction(IDB_STORE, 'readonly')
61+
const req = tx.objectStore(IDB_STORE).getAll()
62+
req.onsuccess = () => {
63+
const all = (req.result ?? []) as Array<CredentialInfo & { credentialIdHex: string }>
64+
// Filter by rpId in JS; small N (typically <100 credentials per RP).
65+
resolve(all.filter((c) => c.rpId === rpId).map(({ credentialIdHex: _, ...rest }) => rest))
66+
}
67+
req.onerror = () => reject(req.error ?? new Error('idb list failed'))
68+
})
69+
}
70+
71+
async function dbPut(c: CredentialInfo): Promise<void> {
72+
const db = await openDb()
73+
return new Promise((resolve, reject) => {
74+
const tx = db.transaction(IDB_STORE, 'readwrite')
75+
tx.objectStore(IDB_STORE).put({ ...c, credentialIdHex: hex(c.credentialId) })
76+
tx.oncomplete = () => resolve()
77+
tx.onerror = () => reject(tx.error ?? new Error('idb put failed'))
78+
})
79+
}
80+
81+
/** Extract SPKI from a PublicKeyCredential's attestationObject.
82+
* Authenticators encode the public key in COSE format inside the
83+
* attestation object; we wrap it as SPKI for downstream OpenSSL
84+
* consumers. For ES256 (the common case) this is a few-line
85+
* encoding; for RSA we leave a TODO. */
86+
async function spkiFromAttestation(cred: PublicKeyCredential): Promise<{ spki: Uint8Array, alg: CoseEnum }> {
87+
// WebAuthn provides getPublicKey() / getPublicKeyAlgorithm() on the
88+
// AuthenticatorAttestationResponse in newer browsers. Use those if
89+
// present; otherwise fall back to manual CBOR decode of the
90+
// attestationObject (Phase 2 enhancement).
91+
const resp = cred.response as AuthenticatorAttestationResponse
92+
if (typeof (resp as unknown as { getPublicKey(): ArrayBuffer | null }).getPublicKey === 'function') {
93+
const spki = (resp as unknown as { getPublicKey(): ArrayBuffer | null }).getPublicKey()
94+
const algNum = (resp as unknown as { getPublicKeyAlgorithm(): number }).getPublicKeyAlgorithm()
95+
if (spki) {
96+
return { spki: new Uint8Array(spki), alg: (COSE_NUMBER_TO_ENUM[algNum] ?? 0) as CoseEnum }
97+
}
98+
}
99+
// Fallback: empty SPKI; caller will see CKA_VALUE = []. The
100+
// wit-bridge sign path doesn't need SPKI (the signature comes back
101+
// from navigator.credentials.get directly); the SPKI is only needed
102+
// for cert-chain validation, which authenticator-internal keys
103+
// typically delegate to the platform attestation chain anyway.
104+
return { spki: new Uint8Array(0), alg: 0 as CoseEnum }
105+
}
106+
107+
class WebauthnBridgeInstance implements PluginInstance {
108+
constructor(private readonly defaultRpId: string) {}
109+
110+
getImports(): Record<string, unknown> {
111+
return {
112+
// dash-case form for WIT-method import resolution; bare identifier
113+
// form for jco --instantiation runtime destructuring.
114+
'list-credentials': this.listCredentials.bind(this),
115+
listCredentials: this.listCredentials.bind(this),
116+
// sign and register have no dash so one entry covers both forms.
117+
sign: this.sign.bind(this),
118+
register: this.register.bind(this),
119+
}
120+
}
121+
122+
destroy(): void {}
123+
124+
private async listCredentials(rpId: string): Promise<CredentialInfo[]> {
125+
const rp = rpId || this.defaultRpId
126+
try {
127+
return await dbList(rp)
128+
} catch (e) {
129+
throw { tag: 'storage-error', val: String(e) }
130+
}
131+
}
132+
133+
private async sign(credentialId: Uint8Array, challenge: Uint8Array): Promise<Uint8Array> {
134+
let assertion: PublicKeyCredential | null
135+
try {
136+
assertion = (await navigator.credentials.get({
137+
publicKey: {
138+
challenge: challenge.buffer.slice(challenge.byteOffset, challenge.byteOffset + challenge.byteLength) as ArrayBuffer,
139+
allowCredentials: [{
140+
id: credentialId.buffer.slice(credentialId.byteOffset, credentialId.byteOffset + credentialId.byteLength) as ArrayBuffer,
141+
type: 'public-key',
142+
}],
143+
userVerification: 'required',
144+
},
145+
})) as PublicKeyCredential | null
146+
} catch (e) {
147+
throw { tag: 'webauthn-rejected', val: (e as Error).message }
148+
}
149+
if (!assertion) throw { tag: 'webauthn-rejected', val: 'no assertion returned' }
150+
const resp = assertion.response as AuthenticatorAssertionResponse
151+
return new Uint8Array(resp.signature)
152+
}
153+
154+
private async register(
155+
rpId: string,
156+
userName: string,
157+
label: string,
158+
algorithm: CoseEnum,
159+
challenge: Uint8Array,
160+
): Promise<CredentialInfo> {
161+
const rp = rpId || this.defaultRpId
162+
const algNum = COSE_ENUM_TO_NUMBER[algorithm]
163+
let cred: PublicKeyCredential | null
164+
try {
165+
const userIdBytes = new TextEncoder().encode(userName)
166+
cred = (await navigator.credentials.create({
167+
publicKey: {
168+
rp: { id: rp, name: rp },
169+
user: {
170+
id: userIdBytes.buffer.slice(userIdBytes.byteOffset, userIdBytes.byteOffset + userIdBytes.byteLength) as ArrayBuffer,
171+
name: userName,
172+
displayName: label,
173+
},
174+
pubKeyCredParams: [{ type: 'public-key', alg: algNum }],
175+
challenge: challenge.buffer.slice(challenge.byteOffset, challenge.byteOffset + challenge.byteLength) as ArrayBuffer,
176+
authenticatorSelection: { userVerification: 'required' },
177+
timeout: 60_000,
178+
},
179+
})) as PublicKeyCredential | null
180+
} catch (e) {
181+
throw { tag: 'webauthn-rejected', val: (e as Error).message }
182+
}
183+
if (!cred) throw { tag: 'webauthn-rejected', val: 'no credential returned' }
184+
const { spki, alg } = await spkiFromAttestation(cred)
185+
const info: CredentialInfo = {
186+
credentialId: new Uint8Array(cred.rawId),
187+
rpId: rp,
188+
algorithm: alg,
189+
publicKeySpki: spki,
190+
label,
191+
}
192+
try {
193+
await dbPut(info)
194+
} catch (e) {
195+
throw { tag: 'storage-error', val: String(e) }
196+
}
197+
return info
198+
}
199+
}
200+
201+
export const webauthnBrowserImplementation: Implementation = {
202+
name: 'browser',
203+
description: 'WebAuthn bridge via navigator.credentials.{create, get} + IndexedDB credential roster',
204+
create(config: PluginConfig): PluginInstance {
205+
const defaultRpId =
206+
(config.options?.['rpId'] as string | undefined) ??
207+
(typeof window !== 'undefined' ? window.location.hostname : 'localhost')
208+
return new WebauthnBridgeInstance(defaultRpId)
209+
},
210+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* tegmentum:webauthn-bridge — browser-side polyfill plugin.
3+
*
4+
* Satisfies the bridge WIT that pkcs11-webauthn-adapter imports.
5+
* See ../webauthn-bridge/plugin.ts for the plugin definition and
6+
* adapter.ts for the navigator.credentials.* implementation.
7+
*/
8+
export { webauthnBrowserImplementation } from './adapter.js'
9+
export { webauthnBridgePlugin, WEBAUTHN_BRIDGE_INTERFACE } from './plugin.js'
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { WasiPlugin, WasiInterface } from '../../core/types.js'
2+
import { createPlugin } from '../plugin-base.js'
3+
import { webauthnBrowserImplementation } from './adapter.js'
4+
5+
/** tegmentum:webauthn-bridge/bridge — narrow host interface the
6+
* pkcs11-webauthn-adapter wasm component imports. */
7+
export const WEBAUTHN_BRIDGE_INTERFACE: WasiInterface = {
8+
package: 'tegmentum:webauthn-bridge',
9+
name: 'bridge',
10+
version: '0.1.0',
11+
}
12+
13+
/**
14+
* WebAuthn bridge plugin.
15+
*
16+
* Pair with the pkcs11-webauthn-adapter wasm component (Layer-4
17+
* alternative to pkcs11-provider+softhsm or pkcs11-gateway-adapter).
18+
* Maps the bridge's list-credentials / sign / register calls to
19+
* `navigator.credentials.*` + an IndexedDB-backed credential roster.
20+
*
21+
* Configuration (per `tunneled.options` block):
22+
* rpId — RP id passed to navigator.credentials.create/get.
23+
* Defaults to window.location.hostname; override for
24+
* cross-origin scenarios.
25+
*/
26+
export const webauthnBridgePlugin: WasiPlugin = createPlugin(
27+
WEBAUTHN_BRIDGE_INTERFACE,
28+
{ browser: webauthnBrowserImplementation },
29+
'browser',
30+
)

0 commit comments

Comments
 (0)