Skip to content

Publish a SACD document so shares grant document access - #82

Merged
JamesReate merged 3 commits into
mainfrom
feat/sacd-document-for-shares
Aug 28, 2026
Merged

JamesReate merged 3 commits into
mainfrom
feat/sacd-document-for-shares

Conversation

@zer0stars

@zer0stars zer0stars commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

The problem

A share writes permission bits on chain and points at a SACD document through the contract's source field. We passed "".

Permission bits govern telemetry. They say nothing about documents — dimo-app-backend gates a grantee's glovebox on a cloudevent agreement inside that document (hasDocumentAgreement), and a doc-only share is valid with permissions == 0. So a wallet we shared a vehicle with saw the vehicle on their phone and none of its documents.

Owners were never affected: resolveDocAccess short-circuits on ownership first.

The fix is that the agreements now exist at all. Everything below about signing is what makes the same document also work for a direct EventFilters consumer.

What changed

The share builds the document, signs it as the grantor, uploads it, and records ipfs://<cid>.

Shape follows the SDK's generatePermissionsSACDTemplate: a permission agreement naming each granted privilege, plus four cloudevent agreements over dimo.document.vehicle.*, dimo.document.driver.*, dimo.raw.vehicle.*, dimo.raw.driver.* — matching fleet-pairing's buildDocumentAgreements.

Three details that are silent when wrong:

  • asset is the DID, not the NFT contract. The on-chain call takes the contract; the document takes did:erc721:137:0xbA57…:<tokenId>. Mismatch grants documents on paper and none in practice.
  • The permission vocabularies differ — RawData here, GetRawData in the document. defaultPermissionList() feeds both the packed mask and the document, with a test that they can't drift.
  • specversion is lower case, per the SACD spec and cloudevent.RawEvent's struct tag. The SDK emits specVersion, which deserializes to empty. Nothing reads it today; matching the spec removes a trap.

Signing

token-exchange-api checks ValidateSignature(record.Data, record.Signature, data.Grantor.Address) — payload is the data object alone, hashed with accounts.TextHash, verified against the grantor by EOA recovery then ERC-1271. The SACD spec agrees: "the cryptographic signature … created by signing the JSON data".

The grantor is the owner's kernel account and the owner never signs, so the signature comes from go-zerodev's SmartAccountPrivateKeySigner — the tenant's registered signer, wrapped in the kernel's EIP-712 domain and tagged with the validator identifier, which the kernel accepts via ERC-1271. SignMessage is deliberately not used: it applies a bare Keccak256 with no EIP-191 prefix.

Why this differs from the SDK, and why that isn't a contradiction

@dimo-network/transactions signs JSON.stringify(template) — the whole document — which cannot validate against record.Data.

It does not follow that SDK-based sharing is broken, and an earlier draft of this PR wrongly said so. The mobile glovebox never reaches that check: dimo-app-backend evaluates a grantee's document authority itself in resolveDocAccess, reading the cloudevent agreements and never looking at signature; the bytes then come back through the backend's own dev-license JWT, whose token exchange sends privileges and no EventFilters, so token-exchange takes its bitmask fallback.

The signature only decides a request carrying EventFilters. Signing data satisfies that path and the mobile one — a superset of the SDK's output, not a disagreement with it.

Risk

Bounded in both directions.

  • Publishing is best-effort. Any failure — no URL, signing error, endpoint down — logs a warning and falls back to the empty source. The floor is today's behaviour.
  • SACD_UPLOAD_URL is unset by default. Nothing changes until it is configured.
  • Telemetry can't regress. ValidateAccess falls back to the on-chain bitmask whenever the source doc is unusable, for any request without EventFilters.

Revocations and the tenant self-grant pass "" deliberately: a revocation grants nothing to describe, and the self-grant has no grantee-facing document.

Testing

gofmt, go vet, golangci-lint (0 issues) and the full suite pass. New tests cover the document shape — including one running hasDocumentAgreement's exact three-condition check — and one pinning the EIP-191 hash the signature must cover.

Not yet exercised against a live share. Before enabling SACD_UPLOAD_URL, do one share on a throwaway vehicle and confirm both halves: the grantee sees documents, and telemetry still resolves.

🤖 Generated with Claude Code

https://claude.ai/code/session_011C3pLCzJDFMNVAX6waiJs6

A share sets permission bits on chain and points at a SACD document through
`source`. We passed "" for the source. Permission bits say what an app may
read; they say nothing about documents — dimo-app-backend gates a grantee's
glovebox on a `cloudevent` agreement inside that document, and a doc-only
share is valid with permissions == 0. So a wallet we shared a vehicle with
saw the vehicle on their phone and none of its documents.

The share now builds the document, signs it as the grantor, uploads it, and
records `ipfs://<cid>`. Shape is the SDK's `generatePermissionsSACDTemplate`:
a permission agreement naming each granted privilege, plus four cloudevent
agreements over dimo.document.vehicle.*, dimo.document.driver.*,
dimo.raw.vehicle.* and dimo.raw.driver.* — the same set fleet-pairing's
buildDocumentAgreements produces.

Signing follows the verifier rather than the producer, which matters because
they disagree. token-exchange checks
ValidateSignature(record.Data, record.Signature, data.Grantor.Address):
the payload is the `data` object alone, and the signature must belong to the
grantor. @dimo-network/transactions signs JSON.stringify of the whole
template instead, which cannot validate against record.Data — so SDK-produced
documents appear to fail this check. We sign `data`.

The grantor is the owner's kernel account and the owner never signs, so the
signature comes from go-zerodev's SmartAccountPrivateKeySigner: the tenant's
registered signer, wrapped in the kernel's EIP-712 domain and tagged with the
validator identifier, which the kernel accepts through ERC-1271. The hash is
accounts.TextHash of the payload — SignMessage is not used, as it applies a
bare Keccak256 the verifier would never reproduce.

Two things keep this from being able to make anything worse. Publishing is
best-effort: any failure logs and falls back to the empty source we shipped
before. And SACD_UPLOAD_URL is unset by default, so behaviour is unchanged
until it is configured.

Revocations and the tenant self-grant pass "" deliberately: a revocation
grants nothing to describe, and the self-grant has no grantee-facing document.
The SACD spec and every example in DIMO-Network/sacd use `specversion`, and
cloudevent.RawEvent — the struct Go readers unmarshal these documents into —
tags it `json:"specversion"`. We emitted `specVersion`, copied from the TS
SDK, which leaves the field empty on the read side.

Nothing reads it today: token-exchange checks `type`, `data` and `signature`
only. Matching the spec costs nothing and removes a trap for whoever starts
checking.

The spec also supports how this branch signs. On the `signature` field:
"typically created by signing the JSON data with the submitter's private key"
— the data, which is what token-exchange verifies over, and not the whole
template the SDK signs.
The comment claimed SDK-produced documents fail token-exchange and implied
SDK-based sharing does not grant document access. That conclusion was wrong.

The mobile glovebox never reaches that check. dimo-app-backend evaluates a
grantee's document authority itself in resolveDocAccess — fetching the source,
verifying its CID, reading the cloudevent agreements — and never looks at the
signature. The bytes then come back through the backend's own dev-license JWT,
whose token exchange sends privileges and no EventFilters, so token-exchange
takes its bitmask fallback.

The signature only decides a request carrying EventFilters. Signing the data
object satisfies that path and the mobile one both, so it is a superset of the
SDK's output rather than a contradiction of it. What fixes the reported bug is
the agreements existing at all.
@JamesReate
JamesReate merged commit 05d032c into main Aug 28, 2026
3 checks passed
@JamesReate
JamesReate deleted the feat/sacd-document-for-shares branch August 28, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants