Publish a SACD document so shares grant document access - #82
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A share writes permission bits on chain and points at a SACD document through the contract's
sourcefield. We passed"".Permission bits govern telemetry. They say nothing about documents —
dimo-app-backendgates a grantee's glovebox on acloudeventagreement inside that document (hasDocumentAgreement), and a doc-only share is valid withpermissions == 0. So a wallet we shared a vehicle with saw the vehicle on their phone and none of its documents.Owners were never affected:
resolveDocAccessshort-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
EventFiltersconsumer.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 fourcloudeventagreements overdimo.document.vehicle.*,dimo.document.driver.*,dimo.raw.vehicle.*,dimo.raw.driver.*— matching fleet-pairing'sbuildDocumentAgreements.Three details that are silent when wrong:
assetis the DID, not the NFT contract. The on-chain call takes the contract; the document takesdid:erc721:137:0xbA57…:<tokenId>. Mismatch grants documents on paper and none in practice.RawDatahere,GetRawDatain the document.defaultPermissionList()feeds both the packed mask and the document, with a test that they can't drift.specversionis lower case, per the SACD spec andcloudevent.RawEvent's struct tag. The SDK emitsspecVersion, which deserializes to empty. Nothing reads it today; matching the spec removes a trap.Signing
token-exchange-apichecksValidateSignature(record.Data, record.Signature, data.Grantor.Address)— payload is thedataobject alone, hashed withaccounts.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.SignMessageis deliberately not used: it applies a bareKeccak256with no EIP-191 prefix.Why this differs from the SDK, and why that isn't a contradiction
@dimo-network/transactionssignsJSON.stringify(template)— the whole document — which cannot validate againstrecord.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-backendevaluates a grantee's document authority itself inresolveDocAccess, reading the cloudevent agreements and never looking atsignature; the bytes then come back through the backend's own dev-license JWT, whose token exchange sends privileges and noEventFilters, so token-exchange takes its bitmask fallback.The signature only decides a request carrying
EventFilters. Signingdatasatisfies that path and the mobile one — a superset of the SDK's output, not a disagreement with it.Risk
Bounded in both directions.
SACD_UPLOAD_URLis unset by default. Nothing changes until it is configured.ValidateAccessfalls back to the on-chain bitmask whenever the source doc is unusable, for any request withoutEventFilters.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 runninghasDocumentAgreement'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