Skip to content

Commit

Permalink
Add initial experimental WebAuthn support
Browse files Browse the repository at this point in the history
  • Loading branch information
FiloSottile committed Jan 11, 2025
1 parent 71f68da commit 6aa7098
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 5 deletions.
8 changes: 4 additions & 4 deletions lib/recipients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Identity, Recipient } from "./index.js"

export function generateIdentity(): Promise<string> {
const scalar = randomBytes(32)
const identity = bech32.encode("AGE-SECRET-KEY-", bech32.toWords(scalar)).toUpperCase()
const identity = bech32.encodeFromBytes("AGE-SECRET-KEY-", scalar).toUpperCase()
return Promise.resolve(identity)
}

Expand All @@ -28,7 +28,7 @@ export async function identityToRecipient(identity: string | CryptoKey): Promise
}

const recipient = await x25519.scalarMultBase(scalar)
return bech32.encode("age", bech32.toWords(recipient))
return bech32.encodeFromBytes("age", recipient)
}

export class X25519Recipient implements Recipient {
Expand Down Expand Up @@ -167,12 +167,12 @@ export class ScryptIdentity implements Identity {
}
}

function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array {
export function encryptFileKey(fileKey: Uint8Array, key: Uint8Array): Uint8Array {
const nonce = new Uint8Array(12)
return chacha20poly1305(key, nonce).encrypt(fileKey)
}

function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null {
export function decryptFileKey(body: Uint8Array, key: Uint8Array): Uint8Array | null {
if (body.length !== 32) {
throw Error("invalid stanza")
}
Expand Down
183 changes: 183 additions & 0 deletions lib/webauthn.ts
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

View workflow job for this annotation

GitHub Actions / test (20.x)

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Cannot find module '../dist/format.js' or its corresponding type declarations.

Check failure on line 7 in lib/webauthn.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Cannot find module '../dist/format.js' or its corresponding type declarations.

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)
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"type": "module",
"exports": {
".": "./dist/index.js"
".": "./dist/index.js",
"./webauthn": "./dist/webauthn.js"
},
"types": "./dist/index.d.ts",
"keywords": [
Expand All @@ -32,6 +33,7 @@
"examples:esbuild": "cd tests/examples && npm update && npm run test:esbuild",
"bench": "vitest bench --run",
"lint": "eslint .",
"serve": "esbuild www/index.ts --bundle --outdir=www/js --servedir=www --sourcemap",
"build": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build"
},
Expand Down
28 changes: 28 additions & 0 deletions www/index.html
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>
7 changes: 7 additions & 0 deletions www/index.ts
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

0 comments on commit 6aa7098

Please sign in to comment.