From 4254e39dac6d57d8f2a855b7dde55e75072eb11d Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Mon, 6 Oct 2025 06:54:43 -0700 Subject: [PATCH] Add passkey support --- .changeset/thirty-wasps-tell.md | 23 ++++ .../service/composite-signature.ts | 1 + packages/sdk/src/encode/encode.test.ts | 21 ++- packages/sdk/src/encode/encode.ts | 30 ++++- .../src/resolve/resolve-signatures.test.ts | 47 ++++++- .../sdk/src/resolve/resolve-signatures.ts | 21 ++- packages/sdk/src/resolve/voucher.ts | 32 +++-- .../src/wallet-utils/encode-signable.test.ts | 18 +++ .../sdk/src/wallet-utils/encode-signable.ts | 14 +- .../src/send/send-get-transaction.js | 8 ++ .../src/send/send-get-transaction.test.js | 36 ++++- .../src/send/send-transaction.js | 12 ++ .../src/send/send-transaction.test.js | 127 ++++++++++++++++++ packages/typedefs/src/index.ts | 4 + packages/typedefs/src/interaction.ts | 4 + 15 files changed, 361 insertions(+), 37 deletions(-) create mode 100644 .changeset/thirty-wasps-tell.md diff --git a/.changeset/thirty-wasps-tell.md b/.changeset/thirty-wasps-tell.md new file mode 100644 index 000000000..d6383c4b2 --- /dev/null +++ b/.changeset/thirty-wasps-tell.md @@ -0,0 +1,23 @@ +--- +"@onflow/transport-http": minor +"@onflow/fcl-core": minor +"@onflow/typedefs": minor +"@onflow/sdk": minor +--- + +Adds support for signature extension data introduced by [FLIP 264](https://github.com/onflow/flips/blob/main/protocol/20250203-webauthn-credential-support.md). + +Users can now include signature extension data in their transactions by returning an additional `extensionData` property in their signing functions. + +```typescript +const authz = (ix: Interaction) => { + return { + addr: "0x1234567890abcdef", + keyId: 0, + signingFunction: (signable: Signable) => ({ + signature: "1234", + extensionData: "1234" + }), + } +} +``` \ No newline at end of file diff --git a/packages/fcl-core/src/normalizers/service/composite-signature.ts b/packages/fcl-core/src/normalizers/service/composite-signature.ts index 9cd8a472d..0b006b128 100644 --- a/packages/fcl-core/src/normalizers/service/composite-signature.ts +++ b/packages/fcl-core/src/normalizers/service/composite-signature.ts @@ -28,6 +28,7 @@ export function normalizeCompositeSignature( addr: sansPrefix(resp.addr || (resp as any).address), signature: resp.signature || (resp as any).sig, keyId: resp.keyId, + ...(resp.extensionData ? {extensionData: resp.extensionData} : {}), } as unknown as CompositeSignature } diff --git a/packages/sdk/src/encode/encode.test.ts b/packages/sdk/src/encode/encode.test.ts index 79168315e..7cfd80ede 100644 --- a/packages/sdk/src/encode/encode.test.ts +++ b/packages/sdk/src/encode/encode.test.ts @@ -166,6 +166,21 @@ describe("encode transaction", () => { "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f87bb07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001d2880000000000000001880000000000000002", "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f8a2f87bb07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001d2880000000000000001880000000000000002e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162", ], + [ + "payload sig with extensionData appends 4th element", + buildTx({ + payloadSigs: [ + { + address: "01", + keyId: 4, + sig: "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162", + extensionData: "abcd", + }, + ], + }), + "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001", + null, + ], ] // Test case format: @@ -236,7 +251,11 @@ describe("encode transaction", () => { test.each(validPayloadCases)( "%s", (_, tx, expectedPayload, expectedEnvelope) => { - expect(encodeTransactionEnvelope(tx)).toBe(expectedEnvelope) + if (expectedEnvelope != null) { + expect(encodeTransactionEnvelope(tx)).toBe(expectedEnvelope) + } else { + expect(() => encodeTransactionEnvelope(tx)).not.toThrow() + } } ) diff --git a/packages/sdk/src/encode/encode.ts b/packages/sdk/src/encode/encode.ts index a767dd0d7..08c6cd171 100644 --- a/packages/sdk/src/encode/encode.ts +++ b/packages/sdk/src/encode/encode.ts @@ -144,6 +144,10 @@ const argumentToString = (arg: Record) => const scriptBuffer = (script: string) => Buffer.from(script, "utf8") const signatureBuffer = (signature: string) => Buffer.from(signature, "hex") +const extensionBuffer = (ext?: string) => { + if (ext == null) return undefined + return Buffer.from(ext, "hex") +} const rlpEncode = (v: EncodeInput) => { return encode(v).toString("hex") @@ -186,6 +190,7 @@ const preparePayloadSignatures = (tx: Transaction) => { signerIndex: signers.get(sansPrefix(sig.address)) || "", keyId: sig.keyId, sig: sig.sig, + sigExt: extensionBuffer(sig.extensionData), } }) .sort((a, b) => { @@ -198,7 +203,9 @@ const preparePayloadSignatures = (tx: Transaction) => { return 0 }) .map(sig => { - return [sig.signerIndex, sig.keyId, signatureBuffer(sig.sig)] + const base: any[] = [sig.signerIndex, sig.keyId, signatureBuffer(sig.sig)] + if (sig.sigExt != null) base.push(sig.sigExt) + return base }) } @@ -229,8 +236,13 @@ const prepareVoucher = (voucher: Voucher) => { const prepareSigs = (sigs: Sig[]) => { return sigs - .map(({address, keyId, sig}) => { - return {signerIndex: signers.get(sansPrefix(address)) || "", keyId, sig} + .map(({address, keyId, sig, extensionData}) => { + return { + signerIndex: signers.get(sansPrefix(address)) || "", + keyId, + sig, + sigExt: extensionBuffer(extensionData), + } }) .sort((a, b) => { if (a.signerIndex > b.signerIndex) return 1 @@ -241,7 +253,13 @@ const prepareVoucher = (voucher: Voucher) => { return 0 }) .map(sig => { - return [sig.signerIndex, sig.keyId, signatureBuffer(sig.sig)] + const base: any[] = [ + sig.signerIndex, + sig.keyId, + signatureBuffer(sig.sig), + ] + if (sig.sigExt != null) base.push(sig.sigExt) + return base }) } @@ -319,6 +337,10 @@ interface Sig { address: string keyId: number | string sig: string + // Optional signature extension data (e.g., WebAuthn clientDataJSON + authenticatorData) + // Propagated via "extensionData" when present by wallets/connectors. + // Not required for existing signatures; encoding remains identical when absent. + extensionData?: string } export interface TransactionProposalKey { diff --git a/packages/sdk/src/resolve/resolve-signatures.test.ts b/packages/sdk/src/resolve/resolve-signatures.test.ts index 654c50d81..ed4312906 100644 --- a/packages/sdk/src/resolve/resolve-signatures.test.ts +++ b/packages/sdk/src/resolve/resolve-signatures.test.ts @@ -64,13 +64,15 @@ test("voucher in signable", async () => { transaction``, limit(156), proposer(authz), - authorizations([authz]), - payer({ - addr: "0x02", - signingFunction: () => ({signature: "123"}), - keyId: 0, - sequenceNum: 123, - }), + authorizations([authz as any]), + payer([ + { + addr: "0x02", + signingFunction: () => ({signature: "123"}), + keyId: 0, + sequenceNum: 123, + }, + ]), ref("123"), ]) ) @@ -93,6 +95,37 @@ test("voucher in signable", async () => { }) }) +test("extensionData is propagated into voucher", async () => { + const authz = { + addr: "0x01", + signingFunction: () => ({signature: "123", extensionData: "abcd"}), + keyId: 0, + sequenceNum: 123, + } + const ix = await resolve( + await build([ + transaction``, + limit(156), + proposer(authz), + authorizations([authz as any]), + payer([ + { + addr: "0x02", + signingFunction: () => ({signature: "123"}), + keyId: 0, + sequenceNum: 123, + }, + ]), + ref("123"), + ]) + ) + + const signable = buildSignable(ix.accounts[ix.proposer!], {} as any, ix) + expect(signable.voucher.payloadSigs[0]).toEqual( + expect.objectContaining({extensionData: "abcd"}) + ) +}) + test("Golden Path", async () => { const ix = await resolveSignatures(TRANSACTION) diff --git a/packages/sdk/src/resolve/resolve-signatures.ts b/packages/sdk/src/resolve/resolve-signatures.ts index 0743a2070..3e64d2e6a 100644 --- a/packages/sdk/src/resolve/resolve-signatures.ts +++ b/packages/sdk/src/resolve/resolve-signatures.ts @@ -36,11 +36,17 @@ export async function resolveSignatures(ix: Interaction) { let outsideSigners = findOutsideSigners(ix) const outsidePayload = encodeOutsideMessage({ ...prepForEncoding(ix), - payloadSigs: insideSigners.map(id => ({ - address: ix.accounts[id].addr || "", - keyId: ix.accounts[id].keyId || 0, - sig: ix.accounts[id].signature || "", - })), + payloadSigs: insideSigners.map(id => { + const base: any = { + address: ix.accounts[id].addr || "", + keyId: ix.accounts[id].keyId || 0, + sig: ix.accounts[id].signature || "", + } + const acc = ix.accounts[id] as InteractionAccount + const ext = (acc as any).extensionData as string | null | undefined + if (ext != null) (base as any).extensionData = ext + return base + }), }) // Promise.all could potentially break the flow if there are multiple outside signers trying to resolve at the same time @@ -62,10 +68,13 @@ function fetchSignature(ix: Interaction, payload: string) { return async function innerFetchSignature(id: string) { const acct = ix.accounts[id] if (acct.signature != null && acct.signature !== undefined) return - const {signature} = await acct.signingFunction( + const {signature, extensionData} = await acct.signingFunction( buildSignable(acct, payload, ix) ) ix.accounts[id].signature = signature + if (extensionData != null) { + ix.accounts[id].extensionData = extensionData + } } } diff --git a/packages/sdk/src/resolve/voucher.ts b/packages/sdk/src/resolve/voucher.ts index 7682e8379..798e7f0c7 100644 --- a/packages/sdk/src/resolve/voucher.ts +++ b/packages/sdk/src/resolve/voucher.ts @@ -1,6 +1,6 @@ import {withPrefix} from "@onflow/util-address" import {Voucher, encodeTxIdFromVoucher} from "../encode/encode" -import {Interaction} from "@onflow/typedefs" +import {Interaction, InteractionAccount} from "@onflow/typedefs" /** * Identifies signers for the transaction payload (authorizers + proposer, excluding payer). @@ -120,18 +120,28 @@ export const createSignableVoucher = (ix: Interaction) => { } const buildInsideSigners = () => - findInsideSigners(ix).map(id => ({ - address: withPrefix(ix.accounts[id].addr), - keyId: ix.accounts[id].keyId, - sig: ix.accounts[id].signature, - })) + findInsideSigners(ix).map(id => { + const base: any = { + address: withPrefix(ix.accounts[id].addr), + keyId: ix.accounts[id].keyId, + sig: ix.accounts[id].signature, + } + const ext = (ix.accounts[id] as InteractionAccount as any).extensionData + if (ext != null) (base as any).extensionData = ext + return base + }) const buildOutsideSigners = () => - findOutsideSigners(ix).map(id => ({ - address: withPrefix(ix.accounts[id].addr), - keyId: ix.accounts[id].keyId, - sig: ix.accounts[id].signature, - })) + findOutsideSigners(ix).map(id => { + const base: any = { + address: withPrefix(ix.accounts[id].addr), + keyId: ix.accounts[id].keyId, + sig: ix.accounts[id].signature, + } + const ext = (ix.accounts[id] as InteractionAccount as any).extensionData + if (ext != null) (base as any).extensionData = ext + return base + }) const proposalKey = ix.proposer ? { diff --git a/packages/sdk/src/wallet-utils/encode-signable.test.ts b/packages/sdk/src/wallet-utils/encode-signable.test.ts index c8c1c8078..0a4a55c8c 100644 --- a/packages/sdk/src/wallet-utils/encode-signable.test.ts +++ b/packages/sdk/src/wallet-utils/encode-signable.test.ts @@ -74,4 +74,22 @@ describe("encode signable", () => { UnableToDetermineMessageEncodingTypeForSignerAddress ) }) + + test("payload sig extensionData is forwarded for envelope encoding", () => { + const withExt = { + ...VOUCHER, + payloadSigs: [ + {address: "0x02", keyId: 1, sig: "123", extensionData: "abcd"}, + ], + } + + const signable = { + ...PAYER_SIGNABLE, + voucher: withExt, + } + + // Should not throw when encoding envelope including an extension on payload sig + const message = encodeMessageFromSignable(signable as any, "0x01") + expect(typeof message).toBe("string") + }) }) diff --git a/packages/sdk/src/wallet-utils/encode-signable.ts b/packages/sdk/src/wallet-utils/encode-signable.ts index 6148a4ddf..8e5e9f25f 100644 --- a/packages/sdk/src/wallet-utils/encode-signable.ts +++ b/packages/sdk/src/wallet-utils/encode-signable.ts @@ -134,10 +134,16 @@ export const encodeMessageFromSignable = ( }, payer: sansPrefix(signable.voucher.payer), authorizers: signable.voucher.authorizers.map(sansPrefix), - payloadSigs: signable.voucher.payloadSigs.map(ps => ({ - ...ps, - address: sansPrefix(ps.address), - })), + payloadSigs: signable.voucher.payloadSigs.map(ps => { + const base: any = { + ...ps, + address: sansPrefix(ps.address), + } + if ((ps as any).extensionData != null) { + base.extensionData = (ps as any).extensionData + } + return base + }), } return isPayloadSigner diff --git a/packages/transport-http/src/send/send-get-transaction.js b/packages/transport-http/src/send/send-get-transaction.js index a86f65be5..0ef5508b8 100644 --- a/packages/transport-http/src/send/send-get-transaction.js +++ b/packages/transport-http/src/send/send-get-transaction.js @@ -36,6 +36,14 @@ export async function sendGetTransaction(ix, context = {}, opts = {}) { address: sig.address, keyId: Number(sig.key_index), signature: sig.signature, + ...(sig.extension_data + ? { + extensionData: context.Buffer.from( + sig.extension_data, + "base64" + ).toString("hex"), + } + : {}), }) const unwrapArg = arg => diff --git a/packages/transport-http/src/send/send-get-transaction.test.js b/packages/transport-http/src/send/send-get-transaction.test.js index 9bd873b02..085014cb0 100644 --- a/packages/transport-http/src/send/send-get-transaction.test.js +++ b/packages/transport-http/src/send/send-get-transaction.test.js @@ -24,8 +24,22 @@ describe("Get Transaction", () => { }, payer: "1654653399040a61", authorizers: [], - payload_signatures: [], - envelope_signatures: [], + payload_signatures: [ + { + address: "1654653399040a61", + key_index: "1", + signature: "001122", + extension_data: Buffer.from("abcd", "hex").toString("base64"), + }, + ], + envelope_signatures: [ + { + address: "1654653399040a61", + key_index: "1", + signature: "aabbcc", + extension_data: Buffer.from("deadbeef", "hex").toString("base64"), + }, + ], } httpRequestMock.mockReturnValue(returnedTransaction) @@ -69,8 +83,22 @@ describe("Get Transaction", () => { }, payer: "1654653399040a61", authorizers: [], - payloadSignatures: [], - envelopeSignatures: [], + payloadSignatures: [ + { + address: "1654653399040a61", + keyId: 1, + signature: "001122", + extensionData: "abcd", + }, + ], + envelopeSignatures: [ + { + address: "1654653399040a61", + keyId: 1, + signature: "aabbcc", + extensionData: "deadbeef", + }, + ], }) }) }) diff --git a/packages/transport-http/src/send/send-transaction.js b/packages/transport-http/src/send/send-transaction.js index a314171fc..9f6badeed 100644 --- a/packages/transport-http/src/send/send-transaction.js +++ b/packages/transport-http/src/send/send-transaction.js @@ -31,6 +31,12 @@ export async function sendTransaction(ix, context = {}, opts = {}) { "base64" ), } + if (acct.extensionData != null) { + const b64 = context.Buffer.from(acct.extensionData, "hex").toString( + "base64" + ) + if (b64 != null) signature.extension_data = b64 + } if ( !payloadSignatures.find( existingSignature => @@ -64,6 +70,12 @@ export async function sendTransaction(ix, context = {}, opts = {}) { "base64" ), } + if (acct.extensionData != null) { + const b64 = context.Buffer.from(acct.extensionData, "hex").toString( + "base64" + ) + if (b64 != null) envelopeSignatures[id].extension_data = b64 + } } } catch (error) { console.error( diff --git a/packages/transport-http/src/send/send-transaction.test.js b/packages/transport-http/src/send/send-transaction.test.js index 27c548dfd..24c3289df 100644 --- a/packages/transport-http/src/send/send-transaction.test.js +++ b/packages/transport-http/src/send/send-transaction.test.js @@ -141,4 +141,131 @@ describe("Transaction", () => { expect(response.transactionId).toBe(returnedTransactionId) }) + + test("SendTransaction with extensionData encodes extension_data", async () => { + const httpRequestMock = jest.fn() + + const returnedTransactionId = "a1b2c3" + + httpRequestMock.mockReturnValue({id: returnedTransactionId}) + + const built = await build([ + transaction`cadence transaction`, + proposer({ + addr: "abc", + keyId: 1, + sequenceNum: 123, + signingFunction: () => ({ + addr: "abc", + keyId: 1, + signature: "abc123", + extensionData: "abcd", + }), + resolve: null, + role: { + proposer: true, + authorizer: false, + payer: false, + param: false, + }, + }), + payer({ + addr: "def", + keyId: 1, + sequenceNum: 123, + signingFunction: () => ({ + addr: "def", + keyId: 1, + signature: "def456", + extensionData: "deadbeef", + }), + resolve: null, + role: { + proposer: false, + authorizer: false, + payer: true, + param: false, + }, + }), + authorizations([ + { + addr: "abc", + keyId: 1, + sequenceNum: 123, + signingFunction: () => ({ + addr: "abc", + keyId: 1, + signature: "abc123", + }), + resolve: null, + role: { + proposer: false, + authorizer: true, + payer: false, + param: false, + }, + }, + ]), + ref("aaaa"), + limit(500), + voucherIntercept(async voucher => { + voucherToTxId(voucher) + }), + ]) + + const resolved = await resolve(built) + + const response = await sendTransaction( + resolved, + { + response: responseADT, + Buffer, + }, + { + httpRequest: httpRequestMock, + node: "localhost", + } + ) + + expect(httpRequestMock.mock.calls.length).toEqual(1) + + const valueSent = httpRequestMock.mock.calls[0][0] + + expect(valueSent).toEqual({ + hostname: "localhost", + path: "/v1/transactions", + method: "POST", + body: { + script: "Y2FkZW5jZSB0cmFuc2FjdGlvbg==", + arguments: [], + reference_block_id: "aaaa", + gas_limit: "500", + payer: "def", + proposal_key: { + address: "abc", + key_index: "1", + sequence_number: "123", + }, + authorizers: ["abc"], + payload_signatures: [ + { + address: "abc", + key_index: "1", + signature: "q8Ej", + extension_data: "q80=", + }, + ], + envelope_signatures: [ + { + address: "def", + key_index: "1", + signature: "3vRW", + extension_data: "3q2+7w==", + }, + ], + }, + }) + + expect(response.transactionId).toBe(returnedTransactionId) + }) }) diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 914da9912..7fcb0e30e 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -189,6 +189,10 @@ export interface CompositeSignature { * Signature as a hex string */ signature: string + /** + * Optional signature extension data for alternative schemes (e.g., WebAuthn) + */ + extensionData?: string } export interface CurrentUser { /** diff --git a/packages/typedefs/src/interaction.ts b/packages/typedefs/src/interaction.ts index 3e770ea4d..ff3409137 100644 --- a/packages/typedefs/src/interaction.ts +++ b/packages/typedefs/src/interaction.ts @@ -68,6 +68,10 @@ export interface InteractionAccount { * The signature for the account */ signature: string | null + /** + * Optional extension data for alternative signature schemes (e.g., WebAuthn) + */ + extensionData?: string | null /** * Function used for signing */