Skip to content

feat(sdk): sign audio uploads and send the uploading user id - #14550

Open
rickyrombo wants to merge 1 commit into
mainfrom
feat/signed-audio-uploads
Open

feat(sdk): sign audio uploads and send the uploading user id#14550
rickyrombo wants to merge 1 commit into
mainfrom
feat/signed-audio-uploads

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Client half of a change to close a content-gating bypass. Server side is OpenAudio/go-openaudio#459 and OpenAudio/go-openaudio#460 — this can merge independently and is inert until those activate.

Why

Storage nodes are moving to attesting on chain which wallet uploaded a file, because that attestation is what will entitle the uploader to name the resulting cids on a track. Today anyone can read a gated track's track_cid off the public API and assert it on their own ungated track, and nothing can tell the difference.

A node can only attest to an uploader it can identify, and right now it cannot: tus metadata carries no wallet, no user id and no signature, so Upload.UserWallet is read from an unsigned metadata key the SDK never sends and sits NULL on every SDK upload.

What changes

uploadTrackFiles accepts userId and Storage accepts an audiusWalletClient. When both are present, audio uploads carry an EIP-712 signature over the user id and a timestamp, with both fields sent alongside so the validator can rebuild the typed data. They are reproduced inside the signed payload, so tampering with either in transit only breaks recovery — it cannot redirect the upload to another user.

Why typed data rather than signed JSON

An earlier revision hashed canonical JSON and used personal_sign. EIP-712 is better on two counts:

  • No canonicalization. The type definition is the encoding, so there is no key ordering or number formatting for client and server to agree on and later drift apart over.
  • Domain separation. A bare hashed payload has none, so a signature produced for any other purpose over the same two fields would be replayable as an upload authorization.

It also matches how this SDK already signs entity-manager writes, so both sides use standard calls rather than a hand-rolled hashing scheme.

The payload deliberately does not name the content: at upload-create time the bytes have not been sent and nobody knows the cid yet. Binding to content happens on the node's side, once transcoding produces cids it can attest to.

What is deliberately unchanged

Image uploads. They are served unauthenticated so there is no claim to protect, and requiring a signature would break signup, which uploads a profile picture before the account has a user id. The ordering nuance is that the Hedgehog wallet does exist by then — what does not is the user id, minted later inside createUserWithEntityManager. That is why the payload is keyed on the wallet.

Unsigned uploads still succeed. Without a wallet client or user id the upload proceeds and simply never earns an attestation, failing later at the point of claiming rather than blocking the upload.

Testing

5 tests covering the returned shape, that the signature verifies against the agreed domain and types, binding to both the user id and the timestamp, and the no-wallet error.

There is a matching fixture test on the validator side pinning a real signature produced by this code, so the two independently-written implementations cannot drift apart silently. I confirmed a viem-signed payload recovers to the expected address in Go before wiring it up.

Both packages/sdk and packages/common typecheck clean.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ec31ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@audius/sdk Minor
@audius/sdk-legacy Patch
@audius/protocol-dashboard Patch
@audius/sp-actions Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rickyrombo
rickyrombo force-pushed the feat/signed-audio-uploads branch 2 times, most recently from 0dee8e0 to ed0ecb3 Compare August 7, 2026 06:35
@rickyrombo
rickyrombo force-pushed the feat/signed-audio-uploads branch from ed0ecb3 to 590911e Compare August 7, 2026 07:29
Storage nodes are moving to attesting on chain which wallet uploaded a file,
because that attestation is what will entitle the uploader to name the
resulting cids on a track. A node can only attest to an uploader it can
identify, and today it cannot: tus metadata carried no wallet, no user id and
no signature, so Upload.UserWallet was read from an unsigned metadata key the
SDK never sent and sat NULL on every SDK upload.

Audio uploads now carry an EIP-712 signature over the user id and a timestamp,
with both fields sent alongside so the validator can rebuild the typed data.
They are reproduced inside the signed payload, so tampering with either in
transit only breaks recovery — it cannot redirect the upload to another user.

Typed data rather than a hashed JSON payload for two reasons. It removes JSON
canonicalization from the protocol entirely: the type definition is the
encoding, so there is no key ordering or number formatting for client and
server to agree on and later drift apart over. And it provides domain
separation, which a bare hashed payload does not — without a domain, a
signature produced for any other purpose over the same two fields would be
replayable as an upload authorization. It also matches how the SDK already
signs entity-manager writes, so both sides use standard calls rather than a
hand-rolled hashing scheme.

The payload deliberately does not name the content. At upload-create time the
bytes have not been sent and nobody knows the cid yet; binding to content
happens on the node's side, once transcoding produces cids it can attest to.

There is a matching fixture test on the validator side pinning a real signature
produced here, so the two implementations cannot drift apart silently.

Image uploads are untouched. They are served unauthenticated so there is no
claim to protect, and requiring a signature would break signup, which uploads a
profile picture before the account has a user id — the wallet exists by then,
which is why the payload is keyed on the wallet rather than the user id.

Signing is best effort: without a wallet client or user id the upload proceeds
unsigned and simply never earns an attestation, failing later at the point of
claiming rather than blocking the upload itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the feat/signed-audio-uploads branch from 590911e to 8ec31ef Compare August 7, 2026 09:13
rickyrombo added a commit that referenced this pull request Aug 7, 2026
Found while adding EIP-712 upload signing in #14550. Independent of that
stack.

`packages/sdk/src/sdk/utils/signatureSchemas.ts` exported `domains`,
`types`, and `generators` alongside `getNonce`, but **only `getNonce`
has ever been imported.** `EntityManagerClient` builds its typed data
from the contract package's `EntityManager.types` instead, and
`sdk/index.ts` re-exports only `parseParams`, `rendezvous`, `errors`,
and `hashId` — not this module. So the rest is unreachable, and removing
it is not a breaking change.

## Why bother

Not the line count. It is an attractive nuisance: it is the first thing
you find when adding EIP-712 signing to the SDK, and what you find is
the wrong definition — contract-shaped with `chainId` and
`verifyingContract`, and typing `userId` as `uint` rather than
`uint256`. That last one is what the `// @ts-ignore Need to update this
type to "uint32" instead of "uint"` in `EntityManagerClient` is working
around.

I reached for it myself before checking whether anything used it.

## Renamed to `nonce.ts`

Two reasons. It matches what is left, and there is a **live**
`packages/libs/src/data-contracts/signatureSchemas.js` — used by
`identity-service/rateLimiter`, `AudiusABIDecoder`, and libs' own
`EntityManagerClient`. That one is untouched here. Two files with the
same name where one is dead invites editing the wrong one; I conflated
them myself at first.

## Verification

`packages/sdk` and `packages/common` typecheck clean, the SDK builds,
and the EntityManager tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Marcus Pasell <marcus@audius.co>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant