feat(plugins) : add new api api method : webauthn.getOrCreate
This commit is contained in:
committed by
Linus Rath
parent
622adc34de
commit
a08a9e9ed3
@@ -22,6 +22,7 @@ const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
|||||||
'jmap.fetchBlob',
|
'jmap.fetchBlob',
|
||||||
'jmap.sendRaw',
|
'jmap.sendRaw',
|
||||||
'upfiles.get',
|
'upfiles.get',
|
||||||
|
'webauthn.getOrCreate',
|
||||||
'upfiles.set',
|
'upfiles.set',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// To just read, use jmap.fetchBlob.
|
// To just read, use jmap.fetchBlob.
|
||||||
'upfiles.get' : 'email:blob-write',
|
'upfiles.get' : 'email:blob-write',
|
||||||
'upfiles.save' : 'email:blob-write',
|
'upfiles.save' : 'email:blob-write',
|
||||||
|
'webauthn.getOrCreate': 'crypto:full',
|
||||||
// admin
|
// admin
|
||||||
'admin.getConfig': 'admin:config',
|
'admin.getConfig': 'admin:config',
|
||||||
'admin.getAllConfig': 'admin:config',
|
'admin.getAllConfig': 'admin:config',
|
||||||
@@ -274,6 +276,117 @@ async function doJmapSendRaw(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// This salt acts as a constant context identifier for key derivation.
|
||||||
|
// While hardcoded, security is maintained because the WebAuthn PRF extension
|
||||||
|
// mixes this salt with the device's unique, hardware-bound private key.
|
||||||
|
// Changing this string will result in a completely different derived secret.
|
||||||
|
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||||
|
* This secret is typically used as a local master encryption key.
|
||||||
|
*/
|
||||||
|
async function doGetOrCreatePRF(
|
||||||
|
masterCredentialIdBytes: number[] | undefined,
|
||||||
|
name?: string,
|
||||||
|
displayName?: string
|
||||||
|
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
||||||
|
|
||||||
|
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||||
|
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||||
|
const credentialId = new Uint8Array(masterCredentialIdBytes).buffer;
|
||||||
|
|
||||||
|
// Request an assertion (login) while evaluating the PRF salt
|
||||||
|
const assertion = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
|
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||||
|
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
||||||
|
extensions: { prf: { eval: { first: PRF_SALT } } } as any
|
||||||
|
}
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
// Extract the derived symmetric key from the authenticator's output
|
||||||
|
const outputs = assertion.getClientExtensionResults();
|
||||||
|
const prfSecret = (outputs as any).prf?.results?.first;
|
||||||
|
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||||
|
|
||||||
|
return {
|
||||||
|
credentialId: masterCredentialIdBytes,
|
||||||
|
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CASE 2: No masterCredentialIdBytes passed, create a new key (Registration) ──────────
|
||||||
|
else if (name && displayName) {
|
||||||
|
// Create the new passkey credential
|
||||||
|
const credential = await navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
|
rp: { name: "Bulwark Webmail", id: window.location.hostname },
|
||||||
|
user: {
|
||||||
|
id: crypto.getRandomValues(new Uint8Array(16)),
|
||||||
|
name: name,
|
||||||
|
displayName: displayName
|
||||||
|
},
|
||||||
|
// Supported cryptographic algorithms
|
||||||
|
pubKeyCredParams: [
|
||||||
|
{ type: "public-key" as const, alg: -7 }, // ES256 (Recommended)
|
||||||
|
{ type: "public-key" as const, alg: -257 } // RS256 (Compatibility fallback)
|
||||||
|
],
|
||||||
|
authenticatorSelection: {
|
||||||
|
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||||
|
userVerification: "required"
|
||||||
|
},
|
||||||
|
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
||||||
|
}
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
const outputs = credential.getClientExtensionResults();
|
||||||
|
|
||||||
|
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||||
|
const isPrfEnabled = (outputs as any).prf?.enabled;
|
||||||
|
if (!isPrfEnabled) {
|
||||||
|
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Since many authenticators do not return the PRF evaluation results
|
||||||
|
// directly during creation, we immediately run an assertion (get) to fetch the initial secret.
|
||||||
|
const assertion = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
|
allowCredentials: [{
|
||||||
|
type: "public-key",
|
||||||
|
id: credential.rawId
|
||||||
|
}],
|
||||||
|
userVerification: "required",
|
||||||
|
extensions: {
|
||||||
|
prf: { eval: { first: PRF_SALT } }
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
|
const assertionOutputs = assertion.getClientExtensionResults();
|
||||||
|
|
||||||
|
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
||||||
|
if (!prfSecret) {
|
||||||
|
return 'Cannot get PRF secret from existing credential.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
||||||
|
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CASE 3: Insufficient parameters provided ───────────────────────────
|
||||||
|
else {
|
||||||
|
throw new Error("Provide name and display name if you want to create a new PRF.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Uploaded files in IndexedDB (privileged tier) ──────────────────────────
|
// ─── Uploaded files in IndexedDB (privileged tier) ──────────────────────────
|
||||||
|
|
||||||
async function getFile(fileID:string): Promise<File | null> {
|
async function getFile(fileID:string): Promise<File | null> {
|
||||||
@@ -361,6 +474,7 @@ export async function dispatchApiCall(
|
|||||||
);
|
);
|
||||||
case 'upfiles.get' : return getFile(args[0] as string);
|
case 'upfiles.get' : return getFile(args[0] as string);
|
||||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
||||||
|
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
|
||||||
|
|
||||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||||
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
|
|||||||
export const API_METHODS = [
|
export const API_METHODS = [
|
||||||
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
|
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
|
||||||
'http.post', 'http.fetch',
|
'http.post', 'http.fetch',
|
||||||
|
'webauthn.getOrCreate',
|
||||||
'jmap.fetchBlob', 'jmap.sendRaw',
|
'jmap.fetchBlob', 'jmap.sendRaw',
|
||||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
settings: { ...manifest.settings },
|
settings: { ...manifest.settings },
|
||||||
},
|
},
|
||||||
|
webauthn: {
|
||||||
|
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
|
||||||
|
},
|
||||||
storage: {
|
storage: {
|
||||||
get: (key: string) => callApi('storage.get', [key]),
|
get: (key: string) => callApi('storage.get', [key]),
|
||||||
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
|
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
|
||||||
|
|||||||
Reference in New Issue
Block a user