-
-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add initial experimental WebAuthn support
- Loading branch information
1 parent
71f68da
commit 6aa7098
Showing
5 changed files
with
225 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
import { bech32, base64nopad } from "@scure/base" | ||
import { randomBytes } from "@noble/hashes/utils" | ||
import { Identity, Recipient } from "./index.js" | ||
import { extract } from "@noble/hashes/hkdf" | ||
import { sha256 } from "@noble/hashes/sha256" | ||
import { decryptFileKey, encryptFileKey } from "./recipients.js" | ||
import { Stanza } from "../dist/format.js" | ||
Check failure on line 7 in lib/webauthn.ts GitHub Actions / test (20.x)
Check failure on line 7 in lib/webauthn.ts GitHub Actions / test (22.x)
Check failure on line 7 in lib/webauthn.ts GitHub Actions / build
Check failure on line 7 in lib/webauthn.ts GitHub Actions / build
Check failure on line 7 in lib/webauthn.ts GitHub Actions / test (18.x)
Check failure on line 7 in lib/webauthn.ts GitHub Actions / test (20.x)
|
||
|
||
export interface CreationOptions { | ||
issuerName: string; | ||
keyName: string; | ||
|
||
// If securityKey is true, pass the "security-key" hint to the | ||
// authenticator, which will prompt a UI for hardware tokens only. | ||
securityKey?: boolean; | ||
|
||
// If resident is false, pass the "discouraged" residentKey option to the | ||
// authenticator. This generally has no effect if the user chooses a passkey | ||
// authenticator, so it might be useful to combine with securityKey. | ||
// | ||
// The returned identity string MUST be available to encrypt and decrypt | ||
// files, and CAN'T be regenerated if lost. | ||
// | ||
// resident defaults to true. | ||
resident?: boolean; | ||
} | ||
|
||
// We don't actually use the public key, so declare support for all default | ||
// algorithms that might be supported by stores. | ||
const defaultAlgorithms: PublicKeyCredentialParameters[] = [ | ||
{ type: "public-key", alg: -8 }, | ||
{ type: "public-key", alg: -7 }, | ||
{ type: "public-key", alg: -257 }, | ||
] | ||
|
||
declare global { | ||
interface PublicKeyCredentialCreationOptions { | ||
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#hints | ||
hints?: ("security-key" | "client-device" | "hybrid")[]; | ||
} | ||
interface PublicKeyCredentialRequestOptions { | ||
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#hints | ||
hints?: ("security-key" | "client-device" | "hybrid")[]; | ||
} | ||
} | ||
|
||
export async function createCredential(options: CreationOptions): Promise<{ | ||
identity: string, credential: PublicKeyCredential | ||
}> { | ||
const cred = await navigator.credentials.create({ | ||
publicKey: { | ||
rp: { name: options.issuerName }, | ||
user: { | ||
name: options.keyName, | ||
id: randomBytes(8), | ||
displayName: "", | ||
}, | ||
pubKeyCredParams: defaultAlgorithms, | ||
authenticatorSelection: { | ||
residentKey: options.resident === false ? "discouraged" : "required", | ||
}, | ||
hints: options.securityKey ? ["security-key"] : [], | ||
extensions: { prf: {} }, | ||
challenge: randomBytes(16), // unused without attestation | ||
}, | ||
}) as PublicKeyCredential | ||
if (!cred.getClientExtensionResults().prf?.enabled) { | ||
throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") | ||
} | ||
return { identity: encodeIdentity(cred), credential: cred } | ||
} | ||
|
||
function encodeIdentity(credential: PublicKeyCredential): string { | ||
const credId = new Uint8Array(credential.rawId) | ||
const identityData = new Uint8Array(credId.length + 1) | ||
identityData[0] = 0x01 // version and flags | ||
identityData.set(credId, 1) | ||
return bech32.encode("AGE-PLUGIN-PRF-", bech32.toWords(identityData), false).toUpperCase() | ||
} | ||
|
||
function decodeIdentity(identity: string): Uint8Array { | ||
const res = bech32.decodeToBytes(identity) | ||
if (!identity.startsWith("AGE-PLUGIN-PRF-1") || res.bytes.length < 2 || res.bytes[0] !== 0x01) { | ||
throw Error("invalid identity") | ||
} | ||
return res.bytes.subarray(1) | ||
} | ||
|
||
export interface Options { | ||
// If identity is set, the file will be encrypted with this specific | ||
// credential. Otherwise, the user will be prompted to select one from those | ||
// available for the origin (which might include login credentials, which | ||
// won't work). | ||
identity?: string; | ||
|
||
securityKey?: boolean; | ||
} | ||
|
||
const label = "age-encryption.org/prf" | ||
|
||
class WebAuthn { | ||
private identity: Uint8Array | null | ||
private securityKey: boolean | ||
|
||
constructor(options: Options) { | ||
this.identity = options.identity ? decodeIdentity(options.identity) : null | ||
this.securityKey = options.securityKey ?? false | ||
} | ||
|
||
async getCredential(nonce: Uint8Array): Promise<AuthenticationExtensionsPRFValues> { | ||
const assertion = await navigator.credentials.get({ | ||
publicKey: { | ||
allowCredentials: this.identity ? [{ id: this.identity, type: "public-key" }] : [], | ||
challenge: randomBytes(16), | ||
extensions: { prf: { eval: prfInputs(nonce) } }, | ||
hints: this.securityKey ? ["security-key"] : [], | ||
}, | ||
}) as PublicKeyCredential | ||
const results = assertion.getClientExtensionResults().prf?.results | ||
if (results === undefined) { | ||
throw Error("PRF extension not available (need macOS 15+, Chrome 132+)") | ||
} | ||
return results | ||
} | ||
} | ||
|
||
export class WebAuthnRecipient extends WebAuthn implements Recipient { | ||
async wrapFileKey(fileKey: Uint8Array): Promise<Stanza[]> { | ||
const nonce = randomBytes(16) | ||
const results = await this.getCredential(nonce) | ||
const key = extract(sha256, deriveKey(results), label) | ||
return [new Stanza([label, base64nopad.encode(nonce)], encryptFileKey(fileKey, key))] | ||
} | ||
} | ||
|
||
export class WebAuthnIdentity extends WebAuthn implements Identity { | ||
async unwrapFileKey(stanzas: Stanza[]): Promise<Uint8Array | null> { | ||
for (const s of stanzas) { | ||
if (s.args.length < 1 || s.args[0] !== label) { | ||
continue | ||
} | ||
if (s.args.length !== 2) { | ||
throw Error("invalid prf stanza") | ||
} | ||
const nonce = base64nopad.decode(s.args[1]) | ||
if (nonce.length !== 16) { | ||
throw Error("invalid prf stanza") | ||
} | ||
|
||
const results = await this.getCredential(nonce) | ||
const key = extract(sha256, deriveKey(results), label) | ||
const fileKey = decryptFileKey(s.body, key) | ||
if (fileKey !== null) return fileKey | ||
} | ||
return null | ||
} | ||
} | ||
|
||
// We use both first and second to prevent an attacker from decrypting two files | ||
// at once with a single user verification. | ||
|
||
function prfInputs(nonce: Uint8Array): AuthenticationExtensionsPRFValues { | ||
const prefix = new TextEncoder().encode(label) | ||
const first = new Uint8Array(prefix.length + nonce.length + 1) | ||
first.set(prefix, 0) | ||
first[prefix.length] = 0x01 | ||
first.set(nonce, prefix.length + 1) | ||
const second = new Uint8Array(prefix.length + nonce.length + 1) | ||
second.set(prefix, 0) | ||
second[prefix.length] = 0x02 | ||
second.set(nonce, prefix.length + 1) | ||
return { first, second } | ||
} | ||
|
||
function deriveKey(results: AuthenticationExtensionsPRFValues): Uint8Array { | ||
if (results.second === undefined) { | ||
throw Error("Missing second PRF result") | ||
} | ||
const prf = new Uint8Array(results.first.byteLength + results.second.byteLength) | ||
prf.set(new Uint8Array(results.first as ArrayBuffer), 0) | ||
prf.set(new Uint8Array(results.second as ArrayBuffer), results.first.byteLength) | ||
return extract(sha256, prf, label) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<meta charset="utf-8"> | ||
<title>typage dev</title> | ||
<style> | ||
:root { | ||
font-family: Avenir, Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif; | ||
color-scheme: light dark; | ||
} | ||
body { | ||
max-width: 900px; | ||
margin: 5rem auto; | ||
padding: 0 30px; | ||
} | ||
</style> | ||
<script src="js/index.js"></script> | ||
</head> | ||
<body> | ||
<h1>typage dev</h1> | ||
<p>Open the JavaScript console to access <code>age</code> and <code>ageWebAuthn</code>.</p> | ||
<script> | ||
window.addEventListener('load', () => { | ||
console.log('age and ageWebAuthn modules loaded') | ||
}) | ||
</script> | ||
</body> | ||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import * as age from "../lib/index.js" | ||
import * as ageWebAuthn from "../lib/webauthn.js" | ||
|
||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ | ||
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
(globalThis as any).age = age; | ||
(globalThis as any).ageWebAuthn = ageWebAuthn |