Merge pull request #707 from paulhenry46/prf-secu-fix
fix(plugins): prevent a privileged plugin to get PRF secret of anothe…
This commit is contained in:
@@ -430,21 +430,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
|
|||||||
|
|
||||||
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
// ─── 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.
|
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||||
* This secret is typically used as a local master encryption key.
|
* This secret is typically used as a local master encryption key.
|
||||||
*/
|
*/
|
||||||
async function doGetOrCreatePRF(
|
async function doGetOrCreatePRF(
|
||||||
masterCredentialIdBytes: number[] | undefined,
|
masterCredentialIdBytes: number[] | undefined,
|
||||||
|
pluginId: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
displayName?: string
|
displayName?: string,
|
||||||
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
||||||
|
|
||||||
|
// To avoid a privileged plugin to access secret created from another privileged plugin,
|
||||||
|
// we add the pluginID from manifest in salt.
|
||||||
|
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
|
||||||
|
|
||||||
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||||
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||||
@@ -456,18 +455,18 @@ async function doGetOrCreatePRF(
|
|||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
allowCredentials: [{ type: "public-key", id: credentialId }],
|
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||||
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
||||||
extensions: { prf: { eval: { first: PRF_SALT } } } as any
|
extensions: { prf: { eval: { first: PRF_SALT } } }
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
// Extract the derived symmetric key from the authenticator's output
|
// Extract the derived symmetric key from the authenticator's output
|
||||||
const outputs = assertion.getClientExtensionResults();
|
const outputs = assertion.getClientExtensionResults();
|
||||||
const prfSecret = (outputs as any).prf?.results?.first;
|
const prfSecret = (outputs).prf?.results?.first;
|
||||||
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: masterCredentialIdBytes,
|
credentialId: masterCredentialIdBytes,
|
||||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,14 +491,14 @@ async function doGetOrCreatePRF(
|
|||||||
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||||
userVerification: "required"
|
userVerification: "required"
|
||||||
},
|
},
|
||||||
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
extensions: { prf: {} } // Request PRF extension support from the authenticator
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const outputs = credential.getClientExtensionResults();
|
const outputs = credential.getClientExtensionResults();
|
||||||
|
|
||||||
// Ensure the authenticator successfully enabled and supports the PRF extension
|
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||||
const isPrfEnabled = (outputs as any).prf?.enabled;
|
const isPrfEnabled = (outputs).prf?.enabled;
|
||||||
if (!isPrfEnabled) {
|
if (!isPrfEnabled) {
|
||||||
return 'The authenticator does not support or has rejected the PRF extension.';
|
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||||
}
|
}
|
||||||
@@ -516,20 +515,20 @@ async function doGetOrCreatePRF(
|
|||||||
userVerification: "required",
|
userVerification: "required",
|
||||||
extensions: {
|
extensions: {
|
||||||
prf: { eval: { first: PRF_SALT } }
|
prf: { eval: { first: PRF_SALT } }
|
||||||
} as any
|
}
|
||||||
}
|
}
|
||||||
}) as PublicKeyCredential;
|
}) as PublicKeyCredential;
|
||||||
|
|
||||||
const assertionOutputs = assertion.getClientExtensionResults();
|
const assertionOutputs = assertion.getClientExtensionResults();
|
||||||
|
|
||||||
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
const prfSecret = (assertionOutputs).prf?.results?.first;
|
||||||
if (!prfSecret) {
|
if (!prfSecret) {
|
||||||
return 'Cannot get PRF secret from existing credential.';
|
return 'Cannot get PRF secret from existing credential.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
||||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +737,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 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
|
||||||
|
|
||||||
case 'contact.get': return doContactGet(args[0] as string);
|
case 'contact.get': return doContactGet(args[0] as string);
|
||||||
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
settings: { ...manifest.settings },
|
settings: { ...manifest.settings },
|
||||||
},
|
},
|
||||||
webauthn: {
|
webauthn: {
|
||||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
|
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
get: (key: string) => callApi('storage.get', [key]),
|
get: (key: string) => callApi('storage.get', [key]),
|
||||||
|
|||||||
Reference in New Issue
Block a user