Skip to content

Commit 60a3d87

Browse files
committed
docs: regrade IMPROVEMENT item 1 — a global DOCUMENT_MANAGER_ROLE satisfies the draft's per-subject MUST
1 parent 5059059 commit 60a3d87

5 files changed

Lines changed: 95 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ Aligned the implementation with the updated [ERC-1643](./doc/ERCSpecification/er
140140
Responsibility* rules. The **admin path remains non-conformant by construction** — a write sent
141141
straight to the engine has no execution point in the subject, so the subject emits nothing.
142142
See [`IMPROVEMENT.md`](./IMPROVEMENT.md) item 2.
143-
- Open conformance gaps are tracked in [`IMPROVEMENT.md`](./IMPROVEMENT.md): per-`subject`
144-
authorization (item 1), admin-path call topology (item 2), and enumeration cost (item 4).
143+
- Open items are tracked in [`IMPROVEMENT.md`](./IMPROVEMENT.md): the most severe is admin-path call
144+
topology (item 2); also authorization granularity (item 1) and enumeration cost (item 4).
145145
- CMTAT v3 no longer ships a *standalone* token that consumes an external document engine through its constructor; the standard token stores documents on-chain (`DocumentERC1643Module`). External-engine integration now goes through CMTAT's `DocumentEngineModule` (`setDocumentEngine`). The test suite was updated to exercise this real integration path via a minimal token built on `DocumentEngineModule`.
146146

147147
## v0.3.0

IMPROVEMENT.md

Lines changed: 84 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -23,80 +23,117 @@ tracked separately under [`doc/audits/tools/`](./doc/audits/tools).
2323

2424
| # | Item | Severity | Effort | Kind |
2525
| --- | --- | --- | --- | --- |
26-
| [1](#1--authorization-is-not-per-subject-and-the-hook-cannot-express-it) | Authorization is not per-`subject`, and the hook cannot express it | **High** | Medium | Spec `MUST` |
26+
| [1](#1--authorization-granularity-is-fixed-at-compile-time-the-hook-cannot-express-per-subject-rules) | Authorization granularity is fixed at compile time; the hook cannot express per-`subject` rules | Low¹ | Medium | Extensibility |
2727
| [2](#2--the-admin-path-bypasses-subject-side-erc-1643-emission) | Admin path bypasses subject-side ERC-1643 emission | **Medium** | Small–Medium | Spec `SHOULD` |
2828
| [3](#3--the-engine-advertises-ierc1643-but-is-not-a-usable-erc-1643-endpoint) | Engine advertises `IERC1643` but is not a usable ERC-1643 endpoint | Low | Trivial | Docs |
2929
| [4](#4--enumeration-cost-and-removal-complexity) | Enumeration cost and removal complexity | Low | Medium | Gas |
3030
| [5](#5--the-erc-2771-forwarder-is-a-universal-write-authority) | ERC-2771 forwarder is a universal write authority | Info | Trivial | Docs |
3131
| [6](#6--_removedocument-emits-before-the-state-change) | `_removeDocument` emits before the state change | Info | Trivial | Cosmetic |
3232
| [7](#7--upstream-imultidocumentsubject-manager-discovery) | Upstream: `IMultiDocumentSubject` manager discovery | Info || Upstream |
3333

34-
Item 1 is the only one that changes what the contract can express; everything else is documentation,
35-
gas, or cosmetic. Item 1 is also **source-compatible** for existing deployments — the default hook
36-
bodies would ignore the new argument — so it does not have to wait for a breaking release.
34+
¹ Low for the single-issuer fleet this engine targets, which is the model the draft sets out to
35+
support. **Medium** only for a deployment shared by unrelated issuers — see item 1 for why that
36+
configuration is not supportable today.
37+
38+
**Item 2 is the most severe open item.** Item 1 is the only one that changes what the contract can
39+
*express*, and it is source-compatible for existing deployments (the default hook bodies would ignore
40+
the new argument), so it need not wait for a breaking release. Everything else is documentation, gas,
41+
or cosmetic.
3742

3843
---
3944

40-
## 1 — Authorization is not per-`subject`, and the hook cannot express it
45+
## 1 — Authorization granularity is fixed at compile time; the hook cannot express per-`subject` rules
4146

42-
**Severity:** High · **Effort:** Medium · **Kind:** deviation from a specification `MUST`
47+
**Severity:** Low — **Medium** for a deployment shared across unrelated issuers · **Effort:** Medium
48+
· **Kind:** extensibility + deployment guidance
4349

44-
**Where:** `src/DocumentEngineBase.sol:56`, `:71-84`, `:111-186`; `src/DocumentEngine.sol:46-48`;
50+
**Where:** `src/DocumentEngineBase.sol:56`, `:111-186`; `src/DocumentEngine.sol:46-48`;
4551
`src/DocumentEngineOwnable.sol:39-41`
4652

47-
> Implementations **MUST** authorize writes per `subject`, so that a caller cannot create, update, or
48-
> remove documents for a `subject` it is not permitted to manage.
49-
> — draft §Authorization
53+
### This is not a conformance failure
5054

51-
`DOCUMENT_MANAGER_ROLE` is a single global permission: every holder may write for *every* subject.
52-
`DocumentEngineOwnable` is the same with `owner`. This is precisely the risk the draft's Security
53-
Considerations open with — "a management contract holds the document sets of unrelated subjects
54-
behind a single address" — and the engine's design is, by default, the unmitigated case.
55+
The draft's requirement is:
5556

56-
That alone might be defensible as a deployment choice, since the project's stated model is a single
57-
operator managing a fleet it owns. The structural problem is that the escape hatch does not work:
57+
> Implementations MUST authorize writes per `subject`, so that a caller cannot create, update, or
58+
> remove documents for a `subject` **it is not permitted to manage**.
59+
> — draft §Authorization
5860
59-
```solidity
60-
function _authorizeDocumentManagement() internal view virtual; // no subject parameter
61-
```
61+
The operative words are "not permitted to manage", and what a caller is permitted to manage is
62+
defined by the deployment's own access control. `DOCUMENT_MANAGER_ROLE` is a specific, granted role
63+
whose permission covers every subject the engine serves — so there is no subject its holder is *not*
64+
permitted to manage, and the clause is satisfied. Same for `owner` in `DocumentEngineOwnable`.
65+
66+
This is the case the draft explicitly sets out to support:
6267

63-
A deployment cannot override this hook to implement per-subject rules, because at the point it runs
64-
the subject is not available. In the batch functions the situation is worse — the modifier fires
65-
**once** for the whole call, before any element is read, so even a subject-aware hook would be
66-
bypassed for `batchSetDocuments` / `batchRemoveDocuments`.
68+
> An issuer operating many tokens, funds, or vaults typically maintains one document library and
69+
> **one set of operators**, and duplicating that storage and access-control logic into every subject
70+
> contract is redundant and expensive.
71+
> — draft §Motivation
6772
68-
Consequently the draft's own test case —
73+
A single global operator role over a fleet one issuer controls is that design, not a departure from
74+
it. The draft's test case — "A caller not authorized for a subject failing to create, update, or
75+
remove that subject's documents" — is covered by `testCannotNonAdminSetDocument` and its siblings: an
76+
account without the role is authorized for no subject, and its write reverts.
6977

70-
> A caller not authorized for a subject failing to create, update, or remove that subject's documents.
78+
### What is actually open
7179

72-
— is untestable against this codebase, and is indeed absent from the suite. The existing
73-
`testNonAdmin*` tests cover the globally-unauthorized case only.
80+
The draft's Security Consideration is about **unrelated** subjects:
7481

75-
**Mitigating factor.** The **bound-token path is already per-subject** and cannot be escaped: the
76-
namespace is `_msgSender()`, structurally. A deployment that overrides
77-
`_authorizeDocumentManagement()` to always revert is fully conformant today — but that removes the
78-
admin path entirely rather than scoping it.
82+
> If writes are not authorized per `subject`, any caller permitted to write for one subject can
83+
> modify another subject's legal or operational references.
7984
80-
**Recommendation.** Change the hook signature and call it per subject:
85+
That bites only when one engine instance is shared by parties that do not trust each other — two
86+
issuers, or a service operator hosting documents for external clients. In that deployment a global
87+
role does breach the property, and **this engine cannot currently express the alternative**, because
88+
the authorization hook receives no subject:
8189

8290
```solidity
83-
function _authorizeDocumentManagement(address subject) internal view virtual;
84-
85-
function batchSetDocuments(address[] calldata subjects, ...) external {
86-
for (uint256 i = 0; i < length; ++i) {
87-
_authorizeDocumentManagement(subjects[i]);
88-
_setDocument(subjects[i], names[i], uris[i], hashes[i]);
89-
}
90-
}
91+
function _authorizeDocumentManagement() internal view virtual; // no subject parameter
9192
```
9293

93-
The default implementations stay exactly as they are (`_checkRole(DOCUMENT_MANAGER_ROLE)` /
94-
`_checkOwner()`, ignoring `subject`), so behaviour and gas are effectively unchanged, but a
95-
deployment gains the ability to be conformant — for example a per-subject role
96-
`keccak256("DOCUMENT_MANAGER", subject)`. Token binding needs a separate hook
97-
(`_authorizeTokenBinding()`), since binding has no subject.
98-
99-
Add the draft's missing test case once the hook can express it.
94+
So a deployer cannot subclass their way to per-subject rules; they would have to edit
95+
`DocumentEngineBase`. Two consequences:
96+
97+
1. **A multi-tenant deployment is not supportable today.** The only conformant option is one engine
98+
instance per trust domain — which is fine, and cheap, but is a deployment constraint that should
99+
be written down rather than discovered.
100+
2. **It contradicts the project's own advertised extension model.** The README and `CLAUDE.md`
101+
promise that a deployment changes *who* is authorized by overriding a hook, "not by editing the
102+
management functions". That holds for swapping roles for an owner; it does not hold for making the
103+
decision depend on the subject. The hook is the documented seam, and this is the one axis it
104+
cannot turn.
105+
106+
Related detail, relevant only if the hook ever gains a subject: in the batch functions the modifier
107+
fires **once** for the whole call, before any element is read, so a subject-aware hook would have to
108+
be invoked inside the loops rather than via the modifier.
109+
110+
### Recommendation
111+
112+
Low priority, and **not** required for the single-issuer model this engine targets. Two options:
113+
114+
- *Documentation only* (sufficient today): state in the README that one engine instance serves one
115+
trust domain, and that unrelated issuers should each deploy their own rather than share one.
116+
- *Enable the axis*, if multi-tenant support is ever wanted:
117+
118+
```solidity
119+
function _authorizeDocumentManagement(address subject) internal view virtual;
120+
121+
function batchSetDocuments(address[] calldata subjects, ...) external {
122+
for (uint256 i = 0; i < length; ++i) {
123+
_authorizeDocumentManagement(subjects[i]);
124+
_setDocument(subjects[i], names[i], uris[i], hashes[i]);
125+
}
126+
}
127+
```
128+
129+
The default implementations stay exactly as they are (`_checkRole(DOCUMENT_MANAGER_ROLE)` /
130+
`_checkOwner()`, ignoring `subject`), so behaviour and gas are unchanged and no existing deployment
131+
is affected — a subclass simply gains the option of a per-subject rule such as
132+
`keccak256("DOCUMENT_MANAGER", subject)`. Token binding would need a separate hook
133+
(`_authorizeTokenBinding()`), since binding has no subject.
134+
135+
Note also that the bound-token path is already per-subject and cannot be escaped: the namespace is
136+
`_msgSender()`, structurally. A subject that manages its own documents is unaffected by any of this.
100137

101138
## 2 — The admin path bypasses subject-side ERC-1643 emission
102139

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
> This project has not been audited yet, please use at your own risk. For any questions, please contact [admin@cmta.ch](mailto:admin@cmta.ch).
44
>
5+
> **Known open items** are tracked in **[`IMPROVEMENT.md`](./IMPROVEMENT.md)**. None is an
6+
> exploitable vulnerability, but integrators should read it before relying on the engine — in
7+
> particular item 2 (a write sent straight to the engine leaves an ERC-1643 subject's own events
8+
> unemitted) and item 1 (one engine instance serves **one trust domain**: `DOCUMENT_MANAGER_ROLE`
9+
> covers every subject, so unrelated issuers should each deploy their own engine rather than share
10+
> one).
511
612
The `DocumentEngine` is an external contract to manage documents through [*ERC-1643*](https://github.com/ethereum/EIPs/issues/1643), a standard proposition to manage document on-chain. This standard is notably used by [ERC-1400](https://github.com/ethereum/eips/issues/1411) from Polymath.
713

@@ -364,7 +370,7 @@ slither . --checklist --filter-paths "node_modules,test,forge-std,CMTAT,openzepp
364370

365371
> **Static-analysis output is leads, not findings.** Every dismissal in the feedback files was
366372
> verified against the cited `file:line`, and neither tool can see the specification-level issues
367-
> that matter most here — those are in [`ERC_RESULT.md`](./ERC_RESULT.md).
373+
> that matter most here — those are in [`IMPROVEMENT.md`](./IMPROVEMENT.md).
368374
369375
### Surya
370376

doc/audits/AUDIT_OVERVIEW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ detail, with a recommendation for each, in [`IMPROVEMENT.md`](../../IMPROVEMENT.
5656

5757
| Item | Severity | Where |
5858
| --- | --- | --- |
59-
| Authorization is not per-`subject`, and `_authorizeDocumentManagement()` takes no `subject`, so a deployment cannot make it per-subject by overriding the hook | High | item 1 |
59+
| `_authorizeDocumentManagement()` takes no `subject`, so a deployment cannot make authorization per-subject by overriding the hook. Conformant for the single-issuer fleet the engine targets — `DOCUMENT_MANAGER_ROLE` is permitted to manage every subject — but it means one instance serves one trust domain | Low (Medium if shared across unrelated issuers) | item 1 |
6060
| Admin write path has no execution point in the subject, so an ERC-1643 subject emits nothing for writes sent straight to the engine | Medium | item 2 |
6161
| Engine advertises `IERC1643` but its base functions are `_msgSender()`-scoped, so it is not a usable endpoint for an external consumer | Low | item 3 |
6262
| `_removeDocumentName` is O(n); no paginated enumeration | Low | item 4 — also surfaced by Aderyn L-5 |

doc/audits/tools/v0.4.0/aderyn/aderyn-report-feedback.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ CMTAT upgrade.
3030

3131
| ID | Detector | Sev | Instances | Disposition | Reason (verified against the cited lines) |
3232
| --- | --- | --- | --- | --- | --- |
33-
| L-1 | Centralization Risk | Low | 2 | **By design** | `DocumentEngine.sol:24`, `DocumentEngineOwnable.sol:24`. The whole premise of the contract is that a trusted operator manages documents for a fleet of subjects; `DOCUMENT_MANAGER_ROLE` (and `owner`) are that operator. Documented in the README and analysed in `IMPROVEMENT.md` item 1, which argues the privilege should be *narrowed to per-subject*, not removed. Aderyn cannot express that distinction. |
33+
| L-1 | Centralization Risk | Low | 2 | **By design** | `DocumentEngine.sol:24`, `DocumentEngineOwnable.sol:24`. The whole premise of the contract is that a trusted operator manages documents for a fleet of subjects; `DOCUMENT_MANAGER_ROLE` (and `owner`) are that operator. Documented in the README and analysed in `IMPROVEMENT.md` item 1, which concludes the global role is the correct model for the single-issuer fleet this engine targets. Aderyn cannot express that distinction. |
3434
| L-2 | Unspecific Solidity Pragma | Low | 9 | **By design** | Every file uses `pragma solidity ^0.8.20;`. The caret is intentional so the sources stay consumable as a library by projects on a different `0.8.x`; the compiler actually used for the deployed bytecode is pinned to `0.8.34` in `foundry.toml`, and `foundry.lock` pins every dependency. Verified: no file uses a construct that behaves differently across the allowed range. |
3535
| L-3 | PUSH0 Opcode | Low | 9 | **Environment** | Consequence of `^0.8.20` plus `evm_version = prague`: the compiler emits `PUSH0`, which is unavailable on chains that have not adopted Shanghai. Not a source defect. A deployer targeting such a chain must lower `evm_version` in `foundry.toml` — but CMTAT v3 itself requires `prague`, so that configuration is out of scope for this engine. |
3636
| L-4 | Loop Contains `require`/`revert` | Low | 4 | **By design** | `DocumentEngineBase.sol:124, 142, 156, 170` — the four batch loops. The reverts are raised inside `_setDocument` / `_removeDocument` (`ERC1643InvalidName`, `MultiDocumentInvalidSubject`, `ERC1643MissingDocument`). Batch operations are deliberately **all-or-nothing**: a batch containing one bad entry must not half-apply, since partial application would leave the operator unable to tell which documents were written without re-reading every entry. Skipping bad entries instead would silently drop them. |

0 commit comments

Comments
 (0)