diff --git a/packages/iso-webauthn-varsig/package.json b/packages/iso-webauthn-varsig/package.json index 17f97568..6c9b5463 100644 --- a/packages/iso-webauthn-varsig/package.json +++ b/packages/iso-webauthn-varsig/package.json @@ -1,7 +1,7 @@ { "name": "@le-space/iso-webauthn-varsig", "type": "module", - "version": "0.1.0", + "version": "0.2.0", "description": "WebAuthn varsig encoding/decoding helpers.", "author": "Nico Krause (nicokrause.com)", "license": "MIT", diff --git a/packages/iso-webauthn-varsig/readme.md b/packages/iso-webauthn-varsig/readme.md index 3f9bdbde..d55f1f55 100644 --- a/packages/iso-webauthn-varsig/readme.md +++ b/packages/iso-webauthn-varsig/readme.md @@ -2,6 +2,36 @@ WebAuthn varsig encoding/decoding helpers for Ed25519 and P-256. +Implements the **non-recursive** WebAuthn varsig layout per [Gozala's suggestion](https://github.com/ChainAgnostic/varsig/pull/11#discussion_r2766471176) on the ChainAgnostic varsig spec. + +## Wire Format + +All multi-byte integers are unsigned varint-encoded. + +```abnf +webauthn-varsig = webauthn-varsig-header + client-data-length client-data-json + authenticator-data-length authenticator-data + signature-hash-algorithm encoding-info signature-bytes + +webauthn-varsig-header = varsig-prefix varsig-version inner-algorithm curve webauthn-marker +varsig-prefix = %x34 +varsig-version = %x01 +inner-algorithm = %xED / %xEC ; EdDSA / ECDSA +curve = %xED01 / %x1200 ; Ed25519 / P-256 (multicodec) +webauthn-marker = %x300001 ; private-use multicodec + +client-data-length = 1*unsigned-varint +client-data-json = *OCTET +authenticator-data-length = 1*unsigned-varint +authenticator-data = *OCTET +signature-hash-algorithm = 1*unsigned-varint ; multihash code (default 0x12 = SHA-256) +encoding-info = 1*unsigned-varint ; payload encoding (default 0x5f = raw) +signature-bytes = *OCTET ; raw WebAuthn signature +``` + +This layout avoids recursion (no nested varsig-body) and aligns with the [dialog-db](https://github.com/dialog-db/dialog-db) Go/Rust reference implementation. + ## Usage ```js @@ -18,10 +48,21 @@ const assertion = { signature: new Uint8Array([/* ... */]), } +// Encode with defaults (SHA-256 hash, raw encoding) const varsig = encodeWebAuthnVarsigV1(assertion, 'Ed25519') + +// Or with explicit signature metadata +const varsig2 = encodeWebAuthnVarsigV1(assertion, 'P-256', { + signatureHashAlgorithm: 0x12, // SHA-256 + encodingInfo: 0x5f, // raw +}) + const decoded = decodeWebAuthnVarsigV1(varsig) const clientData = parseClientDataJSON(decoded.clientDataJSON) +console.log(decoded.signatureHashAlgorithm) // 0x12 +console.log(decoded.encodingInfo) // 0x5f + const result = await verifyWebAuthnAssertion(decoded, { expectedOrigin: clientData.origin, expectedRpId: new URL(clientData.origin).hostname, @@ -34,3 +75,4 @@ console.log(result.valid) ## Notes - Ed25519 WebAuthn credentials are not supported on all platforms. If your authenticator does not return an Ed25519 key, you must fall back to P-256. +- **Breaking change in v0.2.0**: The wire format changed from v0.1.0. Encoded bytes from the old format are not compatible with this version. See [issue #1](https://github.com/NiKrause/iso-repo/issues/1) for details. diff --git a/packages/iso-webauthn-varsig/src/decoder.js b/packages/iso-webauthn-varsig/src/decoder.js index 6542699e..74b852ae 100644 --- a/packages/iso-webauthn-varsig/src/decoder.js +++ b/packages/iso-webauthn-varsig/src/decoder.js @@ -1,16 +1,13 @@ -import { varintDecode } from './utils.js' import { - VARSIG_PREFIX, - VARSIG_VERSION, - INNER_EDDSA, - INNER_ECDSA, CURVE_ED25519, CURVE_P256, - MULTIHASH_SHA256, - MULTIHASH_SHA256_LEN, + INNER_ECDSA, + INNER_EDDSA, + VARSIG_PREFIX, + VARSIG_VERSION, WEBAUTHN_WRAPPER, - PAYLOAD_ENCODING_RAW, } from './multicodec.js' +import { varintDecode } from './utils.js' /** * @typedef {import('./types').DecodedVarsigV1} DecodedVarsigV1 @@ -19,14 +16,12 @@ import { */ /** - * Decode a WebAuthn varsig v1 into its components. + * Decode a WebAuthn varsig v1 (non-recursive layout) into its components. * - * Format: - * - prefix (0x34) - * - version (0x01) - * - signature-algorithm metadata (varints) - * - payload-encoding metadata (varint) - * - assertion serialization + * Wire format: + * header: prefix version innerAlgorithm curve webauthnMarker + * body: clientDataLen clientDataJSON authDataLen authenticatorData + * signatureHashAlgorithm encodingInfo signatureBytes * * @param {Uint8Array} varsig * @returns {DecodedVarsigV1} @@ -40,19 +35,14 @@ export function decodeWebAuthnVarsigV1(varsig) { throw new Error('Unsupported varsig header') } + // --- header: 3 varints --- let offset = 2 const [innerAlgorithm, innerAlgorithmLen] = varintDecode(varsig, offset) offset += innerAlgorithmLen const [curve, curveLen] = varintDecode(varsig, offset) offset += curveLen - const [multihashCode, multihashCodeLen] = varintDecode(varsig, offset) - offset += multihashCodeLen - const [multihashLength, multihashLengthLen] = varintDecode(varsig, offset) - offset += multihashLengthLen const [webauthnMarker, markerLen] = varintDecode(varsig, offset) offset += markerLen - const [payloadEncoding, payloadEncodingLen] = varintDecode(varsig, offset) - offset += payloadEncodingLen /** @type {SignatureAlgorithm | null} */ const algorithm = @@ -76,22 +66,21 @@ export function decodeWebAuthnVarsigV1(varsig) { throw new Error(`Unexpected P-256 curve code: 0x${curve.toString(16)}`) } - if (multihashCode !== MULTIHASH_SHA256) { - throw new Error('Unsupported multihash header') - } - - if (multihashLength !== MULTIHASH_SHA256_LEN) { - throw new Error('Unsupported multihash header length') - } - if (webauthnMarker !== WEBAUTHN_WRAPPER) { throw new Error('Missing WebAuthn extension marker') } - if (payloadEncoding !== PAYLOAD_ENCODING_RAW) { - throw new Error('Unsupported payload encoding') + // --- body: clientData, authData, signature metadata, signature --- + const [clientDataLen, clientDataLenLen] = varintDecode(varsig, offset) + offset += clientDataLenLen + + if (offset + clientDataLen > varsig.length) { + throw new Error('Invalid clientDataJSON length') } + const clientDataJSON = varsig.slice(offset, offset + clientDataLen) + offset += clientDataLen + const [authDataLen, authDataLenLen] = varintDecode(varsig, offset) offset += authDataLenLen @@ -102,15 +91,10 @@ export function decodeWebAuthnVarsigV1(varsig) { const authenticatorData = varsig.slice(offset, offset + authDataLen) offset += authDataLen - const [clientDataLen, clientDataLenLen] = varintDecode(varsig, offset) - offset += clientDataLenLen - - if (offset + clientDataLen > varsig.length) { - throw new Error('Invalid clientDataJSON length') - } - - const clientDataJSON = varsig.slice(offset, offset + clientDataLen) - offset += clientDataLen + const [signatureHashAlgorithm, sigHashLen] = varintDecode(varsig, offset) + offset += sigHashLen + const [encodingInfo, encInfoLen] = varintDecode(varsig, offset) + offset += encInfoLen const signature = varsig.slice(offset) if (signature.length === 0) { @@ -121,12 +105,11 @@ export function decodeWebAuthnVarsigV1(varsig) { algorithm, innerAlgorithm, curve, - multihashCode, - multihashLength, webauthnMarker, - payloadEncoding, authenticatorData, clientDataJSON, + signatureHashAlgorithm, + encodingInfo, signature, } } diff --git a/packages/iso-webauthn-varsig/src/encoder.js b/packages/iso-webauthn-varsig/src/encoder.js index b25feb81..74254ae9 100644 --- a/packages/iso-webauthn-varsig/src/encoder.js +++ b/packages/iso-webauthn-varsig/src/encoder.js @@ -1,13 +1,12 @@ import { - VARSIG_PREFIX, - VARSIG_VERSION, - INNER_EDDSA, - INNER_ECDSA, CURVE_ED25519, CURVE_P256, + INNER_ECDSA, + INNER_EDDSA, MULTIHASH_SHA256, - MULTIHASH_SHA256_LEN, PAYLOAD_ENCODING_RAW, + VARSIG_PREFIX, + VARSIG_VERSION, WEBAUTHN_WRAPPER, } from './multicodec.js' import { concat, varintEncode } from './utils.js' @@ -15,45 +14,50 @@ import { concat, varintEncode } from './utils.js' /** * @typedef {import('./types').WebAuthnAssertion} WebAuthnAssertion * @typedef {import('./types').SignatureAlgorithm} SignatureAlgorithm + * @typedef {import('./types').EncodeOptions} EncodeOptions */ /** - * Encode a WebAuthn assertion as varsig v1. + * Encode a WebAuthn assertion as varsig v1 (non-recursive layout). * - * Format: - * - varsig prefix (0x34) - * - varsig version (0x01) - * - signature algorithm metadata (varints) - * - payload encoding metadata (varint) - * - assertion serialization + * Wire format: + * header: prefix version innerAlgorithm curve webauthnMarker + * body: clientDataLen clientDataJSON authDataLen authenticatorData + * signatureHashAlgorithm encodingInfo signatureBytes * * @param {WebAuthnAssertion} assertion * @param {SignatureAlgorithm} [algorithm] + * @param {EncodeOptions} [options] */ -export function encodeWebAuthnVarsigV1(assertion, algorithm = 'Ed25519') { +export function encodeWebAuthnVarsigV1( + assertion, + algorithm = 'Ed25519', + options = {} +) { const { authenticatorData, clientDataJSON, signature } = assertion validateWebAuthnAssertion(assertion) const innerAlgorithm = algorithm === 'Ed25519' ? INNER_EDDSA : INNER_ECDSA const curve = algorithm === 'Ed25519' ? CURVE_ED25519 : CURVE_P256 + const sigHashAlg = options.signatureHashAlgorithm ?? MULTIHASH_SHA256 + const encInfo = options.encodingInfo ?? PAYLOAD_ENCODING_RAW const header = new Uint8Array([VARSIG_PREFIX, VARSIG_VERSION]) - const authDataLenBytes = varintEncode(authenticatorData.length) const clientDataLenBytes = varintEncode(clientDataJSON.length) + const authDataLenBytes = varintEncode(authenticatorData.length) return concat([ header, varintEncode(innerAlgorithm), varintEncode(curve), - varintEncode(MULTIHASH_SHA256), - varintEncode(MULTIHASH_SHA256_LEN), varintEncode(WEBAUTHN_WRAPPER), - varintEncode(PAYLOAD_ENCODING_RAW), - authDataLenBytes, - authenticatorData, clientDataLenBytes, clientDataJSON, + authDataLenBytes, + authenticatorData, + varintEncode(sigHashAlg), + varintEncode(encInfo), signature, ]) } @@ -64,7 +68,10 @@ export function encodeWebAuthnVarsigV1(assertion, algorithm = 'Ed25519') { * @param {WebAuthnAssertion} assertion */ export function validateWebAuthnAssertion(assertion) { - if (!assertion.authenticatorData || assertion.authenticatorData.length === 0) { + if ( + !assertion.authenticatorData || + assertion.authenticatorData.length === 0 + ) { throw new Error('authenticatorData is required and cannot be empty') } diff --git a/packages/iso-webauthn-varsig/src/index.js b/packages/iso-webauthn-varsig/src/index.js index 5413eae4..289343d6 100644 --- a/packages/iso-webauthn-varsig/src/index.js +++ b/packages/iso-webauthn-varsig/src/index.js @@ -2,43 +2,40 @@ * WebAuthn Varsig - Public API. */ +export { decodeWebAuthnVarsigV1, parseClientDataJSON } from './decoder.js' + +export { encodeWebAuthnVarsigV1, validateWebAuthnAssertion } from './encoder.js' export { - WEBAUTHN_ED25519, - WEBAUTHN_P256, - ED25519_PUB, - P256_PUB, - VARSIG_PREFIX, - VARSIG_VERSION, - INNER_EDDSA, - INNER_ECDSA, + ALGORITHM_TO_MULTICODEC, CURVE_ED25519, CURVE_P256, + ED25519_PUB, + getAlgorithm, + INNER_ECDSA, + INNER_EDDSA, + isWebAuthnMulticodec, + MULTICODEC_TO_ALGORITHM, MULTIHASH_SHA256, MULTIHASH_SHA256_LEN, + P256_PUB, PAYLOAD_ENCODING_RAW, + VARSIG_PREFIX, + VARSIG_VERSION, + WEBAUTHN_ED25519, + WEBAUTHN_P256, WEBAUTHN_WRAPPER, - ALGORITHM_TO_MULTICODEC, - MULTICODEC_TO_ALGORITHM, - isWebAuthnMulticodec, - getAlgorithm, } from './multicodec.js' - -export { encodeWebAuthnVarsigV1, validateWebAuthnAssertion } from './encoder.js' - -export { decodeWebAuthnVarsigV1, parseClientDataJSON } from './decoder.js' - export { - verifyWebAuthnAssertion, + base64urlToBytes, + bytesEqual, + bytesToBase64url, + concat, + varintDecode, + varintEncode, +} from './utils.js' +export { reconstructSignedData, verifyEd25519Signature, verifyP256Signature, + verifyWebAuthnAssertion, } from './verifier.js' - -export { - varintEncode, - varintDecode, - concat, - base64urlToBytes, - bytesToBase64url, - bytesEqual, -} from './utils.js' diff --git a/packages/iso-webauthn-varsig/src/test-utils.js b/packages/iso-webauthn-varsig/src/test-utils.js index 7709c9af..0bfd64a1 100644 --- a/packages/iso-webauthn-varsig/src/test-utils.js +++ b/packages/iso-webauthn-varsig/src/test-utils.js @@ -11,11 +11,7 @@ * @param {{ userPresent?: boolean, userVerified?: boolean, signCount?: number }} [options] */ export function createMockAuthenticatorData(options = {}) { - const { - userPresent = true, - userVerified = true, - signCount = 1, - } = options + const { userPresent = true, userVerified = true, signCount = 1 } = options const rpIdHash = new Uint8Array(32) for (let i = 0; i < 32; i++) { diff --git a/packages/iso-webauthn-varsig/src/types.ts b/packages/iso-webauthn-varsig/src/types.ts index d0cc55c9..8e126ff3 100644 --- a/packages/iso-webauthn-varsig/src/types.ts +++ b/packages/iso-webauthn-varsig/src/types.ts @@ -20,12 +20,11 @@ export interface DecodedVarsigV1 { algorithm: SignatureAlgorithm innerAlgorithm: number curve: number - multihashCode: number - multihashLength: number webauthnMarker: number - payloadEncoding: number authenticatorData: Uint8Array clientDataJSON: Uint8Array + signatureHashAlgorithm: number + encodingInfo: number signature: Uint8Array } @@ -51,6 +50,16 @@ export interface VarsigOptions { algorithm: SignatureAlgorithm } +/** + * Optional encode parameters for signature metadata. + */ +export interface EncodeOptions { + /** Multihash code for the signature hash algorithm (default: SHA-256 = 0x12). */ + signatureHashAlgorithm?: number + /** Payload encoding info (default: raw = 0x5f). */ + encodingInfo?: number +} + export interface WebAuthnDecoded { authenticatorData: Uint8Array clientDataJSON: Uint8Array diff --git a/packages/iso-webauthn-varsig/src/verifier.js b/packages/iso-webauthn-varsig/src/verifier.js index 58c57a10..2737f91c 100644 --- a/packages/iso-webauthn-varsig/src/verifier.js +++ b/packages/iso-webauthn-varsig/src/verifier.js @@ -111,7 +111,10 @@ export async function verifyWebAuthnAssertion(decoded, options) { } } - if (options.previousSignCount !== undefined && signCount <= options.previousSignCount) { + if ( + options.previousSignCount !== undefined && + signCount <= options.previousSignCount + ) { return { valid: false, error: 'signCount is not monotonic', @@ -160,7 +163,10 @@ export async function reconstructSignedData(decoded) { decoded.authenticatorData.length + clientDataHash.byteLength ) signedData.set(decoded.authenticatorData, 0) - signedData.set(new Uint8Array(clientDataHash), decoded.authenticatorData.length) + signedData.set( + new Uint8Array(clientDataHash), + decoded.authenticatorData.length + ) return signedData } diff --git a/packages/iso-webauthn-varsig/test/varsig.browser.test.js b/packages/iso-webauthn-varsig/test/varsig.browser.test.js index 67c55db0..3fcba1bb 100644 --- a/packages/iso-webauthn-varsig/test/varsig.browser.test.js +++ b/packages/iso-webauthn-varsig/test/varsig.browser.test.js @@ -2,7 +2,11 @@ import { assert, suite } from 'playwright-test/taps' import { decodeWebAuthnVarsigV1, encodeWebAuthnVarsigV1, + MULTIHASH_SHA256, + PAYLOAD_ENCODING_RAW, parseClientDataJSON, + VARSIG_PREFIX, + VARSIG_VERSION, verifyWebAuthnAssertion, } from '../src/index.js' import { @@ -12,7 +16,7 @@ import { createMockP256Assertion, createMockP256Signature, } from '../src/test-utils.js' -import { bytesToBase64url } from '../src/utils.js' +import { bytesToBase64url, varintDecode, varintEncode } from '../src/utils.js' const test = suite('webauthn-varsig') @@ -61,6 +65,8 @@ test('roundtrips Ed25519 varsig', () => { assert.deepEqual(decoded.authenticatorData, assertion.authenticatorData) assert.deepEqual(decoded.clientDataJSON, assertion.clientDataJSON) assert.deepEqual(decoded.signature, assertion.signature) + assert.equal(decoded.signatureHashAlgorithm, MULTIHASH_SHA256) + assert.equal(decoded.encodingInfo, PAYLOAD_ENCODING_RAW) }) test('roundtrips P-256 varsig', () => { @@ -72,6 +78,92 @@ test('roundtrips P-256 varsig', () => { assert.deepEqual(decoded.authenticatorData, assertion.authenticatorData) assert.deepEqual(decoded.clientDataJSON, assertion.clientDataJSON) assert.deepEqual(decoded.signature, assertion.signature) + assert.equal(decoded.signatureHashAlgorithm, MULTIHASH_SHA256) + assert.equal(decoded.encodingInfo, PAYLOAD_ENCODING_RAW) +}) + +test('encodes wire format in correct order', () => { + const assertion = createMockEd25519Assertion() + const varsig = encodeWebAuthnVarsigV1(assertion, 'Ed25519') + + assert.equal(varsig[0], VARSIG_PREFIX) + assert.equal(varsig[1], VARSIG_VERSION) + + let offset = 2 + const [, innerLen] = varintDecode(varsig, offset) + offset += innerLen + const [, curveLen] = varintDecode(varsig, offset) + offset += curveLen + const [, markerLen] = varintDecode(varsig, offset) + offset += markerLen + + const [clientDataLen, cdLenLen] = varintDecode(varsig, offset) + offset += cdLenLen + const clientData = varsig.slice(offset, offset + clientDataLen) + offset += clientDataLen + assert.deepEqual(clientData, assertion.clientDataJSON) + + const [authDataLen, adLenLen] = varintDecode(varsig, offset) + offset += adLenLen + const authData = varsig.slice(offset, offset + authDataLen) + offset += authDataLen + assert.deepEqual(authData, assertion.authenticatorData) + + const [sigHash, shLen] = varintDecode(varsig, offset) + offset += shLen + const [encInfo, eiLen] = varintDecode(varsig, offset) + offset += eiLen + assert.equal(sigHash, MULTIHASH_SHA256) + assert.equal(encInfo, PAYLOAD_ENCODING_RAW) + + assert.deepEqual(varsig.slice(offset), assertion.signature) +}) + +test('accepts custom signatureHashAlgorithm and encodingInfo', () => { + const assertion = createMockEd25519Assertion() + const varsig = encodeWebAuthnVarsigV1(assertion, 'Ed25519', { + signatureHashAlgorithm: 0x13, + encodingInfo: 0x60, + }) + const decoded = decodeWebAuthnVarsigV1(varsig) + + assert.equal(decoded.signatureHashAlgorithm, 0x13) + assert.equal(decoded.encodingInfo, 0x60) + assert.deepEqual(decoded.signature, assertion.signature) +}) + +test('rejects old-format (v0.1) encoded bytes', () => { + const assertion = createMockEd25519Assertion() + const header = new Uint8Array([VARSIG_PREFIX, VARSIG_VERSION]) + const parts = [ + header, + varintEncode(0xed), + varintEncode(0xed01), + varintEncode(0x12), + varintEncode(0x20), + varintEncode(0x300001), + varintEncode(0x5f), + varintEncode(assertion.authenticatorData.length), + assertion.authenticatorData, + varintEncode(assertion.clientDataJSON.length), + assertion.clientDataJSON, + assertion.signature, + ] + let totalLen = 0 + for (const p of parts) totalLen += p.length + const oldFormat = new Uint8Array(totalLen) + let off = 0 + for (const p of parts) { + oldFormat.set(p, off) + off += p.length + } + + assert.throws(() => { + const decoded = decodeWebAuthnVarsigV1(oldFormat) + if (!decoded.authenticatorData.length || !decoded.clientDataJSON.length) { + throw new Error('Decoded garbage') + } + }) }) test('parses clientDataJSON', () => { diff --git a/packages/iso-webauthn-varsig/test/varsig.node.test.js b/packages/iso-webauthn-varsig/test/varsig.node.test.js index 86bdd554..c1dc946f 100644 --- a/packages/iso-webauthn-varsig/test/varsig.node.test.js +++ b/packages/iso-webauthn-varsig/test/varsig.node.test.js @@ -2,7 +2,11 @@ import assert from 'assert' import { decodeWebAuthnVarsigV1, encodeWebAuthnVarsigV1, + MULTIHASH_SHA256, + PAYLOAD_ENCODING_RAW, parseClientDataJSON, + VARSIG_PREFIX, + VARSIG_VERSION, verifyWebAuthnAssertion, } from '../src/index.js' import { @@ -12,7 +16,7 @@ import { createMockP256Assertion, createMockP256Signature, } from '../src/test-utils.js' -import { bytesToBase64url } from '../src/utils.js' +import { bytesToBase64url, varintDecode, varintEncode } from '../src/utils.js' /** * @param {{ rpId: string, userPresent?: boolean, userVerified?: boolean, signCount?: number }} options @@ -57,9 +61,14 @@ describe('iso-webauthn-varsig', () => { const decoded = decodeWebAuthnVarsigV1(varsig) assert.strictEqual(decoded.algorithm, 'Ed25519') - assert.deepStrictEqual(decoded.authenticatorData, assertion.authenticatorData) + assert.deepStrictEqual( + decoded.authenticatorData, + assertion.authenticatorData + ) assert.deepStrictEqual(decoded.clientDataJSON, assertion.clientDataJSON) assert.deepStrictEqual(decoded.signature, assertion.signature) + assert.strictEqual(decoded.signatureHashAlgorithm, MULTIHASH_SHA256) + assert.strictEqual(decoded.encodingInfo, PAYLOAD_ENCODING_RAW) }) it('roundtrips P-256 varsig', () => { @@ -68,9 +77,113 @@ describe('iso-webauthn-varsig', () => { const decoded = decodeWebAuthnVarsigV1(varsig) assert.strictEqual(decoded.algorithm, 'P-256') - assert.deepStrictEqual(decoded.authenticatorData, assertion.authenticatorData) + assert.deepStrictEqual( + decoded.authenticatorData, + assertion.authenticatorData + ) assert.deepStrictEqual(decoded.clientDataJSON, assertion.clientDataJSON) assert.deepStrictEqual(decoded.signature, assertion.signature) + assert.strictEqual(decoded.signatureHashAlgorithm, MULTIHASH_SHA256) + assert.strictEqual(decoded.encodingInfo, PAYLOAD_ENCODING_RAW) + }) + + it('encodes wire format in correct order: header then clientData then authData then sig metadata then sig', () => { + const assertion = createMockEd25519Assertion() + const varsig = encodeWebAuthnVarsigV1(assertion, 'Ed25519') + + // header: prefix + version + assert.strictEqual(varsig[0], VARSIG_PREFIX) + assert.strictEqual(varsig[1], VARSIG_VERSION) + + // skip header varints (innerAlgorithm, curve, webauthnMarker) + let offset = 2 + const [, innerLen] = varintDecode(varsig, offset) + offset += innerLen + const [, curveLen] = varintDecode(varsig, offset) + offset += curveLen + const [, markerLen] = varintDecode(varsig, offset) + offset += markerLen + + // body: clientData first + const [clientDataLen, cdLenLen] = varintDecode(varsig, offset) + offset += cdLenLen + const clientData = varsig.slice(offset, offset + clientDataLen) + offset += clientDataLen + assert.deepStrictEqual(clientData, assertion.clientDataJSON) + + // then authData + const [authDataLen, adLenLen] = varintDecode(varsig, offset) + offset += adLenLen + const authData = varsig.slice(offset, offset + authDataLen) + offset += authDataLen + assert.deepStrictEqual(authData, assertion.authenticatorData) + + // then signatureHashAlgorithm + encodingInfo + const [sigHash, shLen] = varintDecode(varsig, offset) + offset += shLen + const [encInfo, eiLen] = varintDecode(varsig, offset) + offset += eiLen + assert.strictEqual(sigHash, MULTIHASH_SHA256) + assert.strictEqual(encInfo, PAYLOAD_ENCODING_RAW) + + // then raw signature bytes + assert.deepStrictEqual(varsig.slice(offset), assertion.signature) + }) + + it('accepts custom signatureHashAlgorithm and encodingInfo via EncodeOptions', () => { + const assertion = createMockEd25519Assertion() + const customHash = 0x13 // SHA-512 multicodec + const customEnc = 0x60 // arbitrary + const varsig = encodeWebAuthnVarsigV1(assertion, 'Ed25519', { + signatureHashAlgorithm: customHash, + encodingInfo: customEnc, + }) + const decoded = decodeWebAuthnVarsigV1(varsig) + + assert.strictEqual(decoded.signatureHashAlgorithm, customHash) + assert.strictEqual(decoded.encodingInfo, customEnc) + assert.deepStrictEqual(decoded.signature, assertion.signature) + }) + + it('rejects old-format (v0.1) encoded bytes', () => { + // Build a payload in the OLD format: header had multihashCode, multihashLength, payloadEncoding + // and body was authData-first. This must fail to decode correctly. + const assertion = createMockEd25519Assertion() + const header = new Uint8Array([VARSIG_PREFIX, VARSIG_VERSION]) + const parts = [ + header, + varintEncode(0xed), // innerAlgorithm (EdDSA) + varintEncode(0xed01), // curve (Ed25519) + varintEncode(0x12), // multihashCode (old header field) + varintEncode(0x20), // multihashLength (old header field) + varintEncode(0x300001), // webauthnMarker + varintEncode(0x5f), // payloadEncoding (old header field) + varintEncode(assertion.authenticatorData.length), + assertion.authenticatorData, + varintEncode(assertion.clientDataJSON.length), + assertion.clientDataJSON, + assertion.signature, + ] + let totalLen = 0 + for (const p of parts) totalLen += p.length + const oldFormat = new Uint8Array(totalLen) + let off = 0 + for (const p of parts) { + oldFormat.set(p, off) + off += p.length + } + + // Decoding old format should fail or produce wrong data + assert.throws(() => { + const decoded = decodeWebAuthnVarsigV1(oldFormat) + // Even if it doesn't throw, the data won't roundtrip correctly + // because the decoder reads fields in the new order + if (!decoded.authenticatorData.length || !decoded.clientDataJSON.length) { + throw new Error('Decoded garbage') + } + // The webauthnMarker check will fail because 0x12 is read as + // the webauthnMarker instead of 0x300001 + }) }) it('parses clientDataJSON', () => {