From ed6e92445d3b59c5890ad36d1daca98b82c96f20 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 10:31:06 +0100 Subject: [PATCH 1/8] Add FoC standard P256 Authorizer --- .../src/examples/MultiMethodAuthorizer.md | 274 +++++++++++++++++ .../src/examples/MultiMethodAuthorizer.sol | 284 ++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 service_contracts/src/examples/MultiMethodAuthorizer.md create mode 100644 service_contracts/src/examples/MultiMethodAuthorizer.sol diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md new file mode 100644 index 00000000..cb76d2f3 --- /dev/null +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -0,0 +1,274 @@ +# MultiMethodAuthorizer — authorization envelope & credential registry (formal spec) + +Formal description of the wire format ("UCAN"-style delegation envelope) and the on-chain +registry entries consumed by `MultiMethodAuthorizer`, an `IDataSetAuthorizer` for +filecoin-services PR #536. This document is normative for anyone building a client that signs +for this authorizer or a contract that interoperates with it. Source of truth: +[`src/MultiMethodAuthorizer.sol`](src/MultiMethodAuthorizer.sol). + +--- + +## 1. Model + +The authorizer splits a delegated authorization into two halves: + +- **Authentication** travels in the *signature envelope* that FWSS forwards to `isAuthorized` + (Sections 4–5). It proves possession of a P256 key — and, for the passkey method, proves a + human was *present and verified* (biometric) at signing time. It carries **no policy**. +- **Authorization** ("what may this key do") lives in the **on-chain credential registry** + (Section 3), managed by the data set's owner. Each credential enumerates exactly which + operations it may authorize, plus an optional expiry and an enable/disable switch. + +### 1.1 Relationship to UCAN + +This is a UCAN-style *delegation* — a key other than the payer is authorized to act — but the +capability and caveats are held **on-chain**, not inside a signed token. Mapping to UCAN terms: + +| UCAN concept | Here | +|---|---| +| Issuer (`iss`) | The data set **owner** (payer) who calls `addCredential` | +| Audience (`aud`) | The registered **credential** (a P256 public key `(x, y)`) | +| Capability (`can` / resource) | An `(operation, dataSetId)` pair — `allowedOp[credId][operation]` on-chain | +| Caveats | `expiry`, `enabled`, and (passkey) `rpIdHash` + user-verification requirement | +| Proof / invocation signature | The P256 signature over the FWSS operation `digest` (Section 6) | + +Consequence: unlike a token-carried UCAN, **there is no off-chain capability object to parse or +revoke** — delegation is granted and revoked by owner transactions (`addCredential` / +`removeCredential` / `setCredentialEnabled`), and the "challenge" being signed is the FWSS +operation digest itself. Replay/ordering is **not** in this envelope; it is FWSS's responsibility +(see [PLAYBOOK reviewer note](../../repos/filecoin_stuff/synapse-sdk/examples/authz/PLAYBOOK.md)). + +--- + +## 2. Primitives & notation + +- `‖` — byte concatenation. `H(x)` — SHA-256. `keccak(x)` — Keccak-256. +- **P256** — ECDSA over NIST P-256 (secp256r1). Signatures are `(r, s)` with **low-`s`** + normalization required (`s ≤ n/2`, `n` the curve order); high-`s` signatures are rejected by + the precompile and MUST NOT be sent. +- **Verification** is delegated to the FEVM **secp256r1 precompile at `0x100`** (EIP-7951 / + RIP-7212, nv28 / actors v18). Input (160 bytes, big-endian 32-byte words): + + ``` + input = digest‖r‖s‖x‖y # message hash, sig, pubkey + ``` + Output is 32 bytes equal to `uint256(1)` on success, empty/`0` otherwise. +- **base64url** — RFC 4648 §5, **no padding** (the WebAuthn challenge encoding). +- All integers are big-endian. `bytes32(x)` is the 32-byte big-endian encoding of `uint256 x`. + +--- + +## 3. Credential registry (on-chain entries) + +```solidity +enum Method { MachineP256 /*0*/, Passkey /*1*/ } + +struct Credential { + Method method; // 0 = machine key, 1 = WebAuthn passkey + uint256 pubKeyX; // P256 public key X + uint256 pubKeyY; // P256 public key Y + bytes32 rpIdHash; // Passkey only: expected SHA-256(rpId); 0 = accept any origin + uint64 expiry; // unix seconds; 0 = no expiry + bool enabled; // owner kill-switch +} + +mapping(bytes32 credId => Credential) credentials; +mapping(bytes32 credId => mapping(bytes32 operation => bool)) allowedOp; +``` + +**Credential identifier** (deterministic, collision-resistant per method+key): + +``` +credId = keccak(abi.encode(Method method, uint256 x, uint256 y)) +``` + +**Operation identifiers** — the FWSS EIP-712 struct type-hashes (the `operation` argument of +`isAuthorized` and the key of `allowedOp`): + +| Operation | `operation` typehash | +|---|---| +| AddPieces | `0x954bdc254591a7eab1b73f03842464d9283a08352772737094d710a4428fd183` | +| SchedulePieceRemovals | `0x5415701e313bb627e755b16924727217bb356574fe20e7061442c200b0822b22` | +| TerminateService | `0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc` | + +**Owner API** (only the authorizer's `owner`; see PLAYBOOK for cast invocations): +`addCredential(method, x, y, rpIdHash, expiry, ops[])`, `setOperationAllowed`, +`setCredentialEnabled`, `setCredentialExpiry`, `removeCredential(credId, ops[])`, +`transferOwnership`. + +A credential **authorizes** `operation` iff: +`enabled ∧ (expiry == 0 ∨ block.timestamp ≤ expiry) ∧ allowedOp[credId][operation]`. + +### 3.1 `expiry` and time on FEVM + +`expiry` is compared against `block.timestamp`. The FEVM does **not** read wall-clock time — it +synthesizes `block.timestamp` deterministically from the tipset height: + +``` +block.timestamp = genesis_unix + epoch × blocktime # blocktime = 30 s mainnet, 4 s on the FOC devnet +``` + +Consequences an implementer must respect: + +- **Unit is Unix seconds; resolution is one epoch.** `block.timestamp` advances only once per epoch + (30 s mainnet / 4 s devnet) and is constant across a tipset — sub-epoch precision is meaningless. +- **It is consensus-deterministic and not proposer-manipulable** (a pure function of height), so it + is safe to gate on — a stronger guarantee than Ethereum's proposer-influenced timestamp. +- **Anchor `expiry` to chain time, not the local clock.** Chain time only advances when blocks are + produced, so on a devnet (or any idle chain) it can diverge from real wall-clock by hours. Clients + MUST set `expiry = + duration_seconds`, never `Date.now()/1000 + duration`. +- Epoch-native alternative: a variant could gate on `block.number` (which on FEVM is the Filecoin + epoch) and express `expiry` in epochs, removing the genesis/blocktime conversion. This contract + uses `block.timestamp` for EVM-tooling legibility; the semantics above are identical either way. + +--- + +## 4. Signature envelope (outer format) + +The blob FWSS forwards as the `signature` argument of `isAuthorized` is a **method-tagged +envelope**: + +``` +signature = abi.encode(uint8 method, bytes payload) +``` + +| Field | Type | Meaning | +|---|---|---| +| `method` | `uint8` | `0` MachineP256, `1` Passkey. Any other value → **revert** `UnknownMethod` | +| `payload` | `bytes` | Method-specific, decoded per Section 5 | + +Malformed envelopes (undecodable, unknown method) **revert** and bubble; in-scope-but-invalid +authorizations return **`false`** (FWSS maps that to `Unauthorized`). + +--- + +## 5. Method payloads (inner format) + +### 5.1 Method 0 — MachineP256 (a stored key signs the digest directly) + +``` +payload = abi.encode(uint256 x, uint256 y, bytes32 r, bytes32 s) +``` + +| Field | Type | Meaning | +|---|---|---| +| `x`, `y` | `uint256` | P256 public key; selects the credential `credId = keccak(abi.encode(0, x, y))` | +| `r`, `s` | `bytes32` | P256 signature over the FWSS `digest` (low-`s`) | + +Signed message = the FWSS `digest` **verbatim**. + +### 5.2 Method 1 — Passkey (WebAuthn assertion; proves human presence + verification) + +``` +payload = abi.encode(uint256 x, uint256 y, bytes authenticatorData, string clientDataJSON, bytes32 r, bytes32 s) +``` + +| Field | Type | Meaning | +|---|---|---| +| `x`, `y` | `uint256` | P256 public key; selects `credId = keccak(abi.encode(1, x, y))` | +| `authenticatorData` | `bytes` | WebAuthn authenticator data (≥ 37 bytes) | +| `clientDataJSON` | `string` | WebAuthn client data JSON | +| `r`, `s` | `bytes32` | P256 signature over the WebAuthn **message** (below), low-`s` | + +**`authenticatorData` layout** (only the fixed prefix is inspected): + +``` +byte 0..31 rpIdHash = SHA-256(rpId) +byte 32 flags bit0 (0x01) UP user-present; bit2 (0x04) UV user-verified +byte 33..36 signCount uint32 big-endian +byte 37.. (optional attested-credential / extensions — ignored) +``` + +**`clientDataJSON`** MUST be a WebAuthn *get* assertion whose challenge is the FWSS digest. The +authorizer checks, by substring match, that it contains **both**: + +``` +"type":"webauthn.get" +"challenge":"" # no padding; digest is the 32-byte FWSS digest +``` + +(`origin` and other members are not constrained by the contract; `rpIdHash` binding is enforced +via `authenticatorData`, see Section 6.) + +**WebAuthn signed message** (what `(r, s)` signs): + +``` +message = H( authenticatorData ‖ H(clientDataJSON) ) +``` + +--- + +## 6. Verification algorithm (normative) + +`isAuthorized(dataSetId, payer, operation, digest, signature, operationData)`: + +1. Decode the envelope → `(method, payload)`. Unknown `method` ⇒ **revert**. +2. **Machine (0):** decode `(x, y, r, s)`; `credId = keccak(abi.encode(0, x, y))`. + - If credential does not *authorize* `operation` (Section 3) ⇒ return `false`. + - If `P256Verify(digest, r, s, x, y) ≠ 1` ⇒ return `false`. + - Else emit `Authorized(credId, operation, MachineP256)`; return `true`. +3. **Passkey (1):** decode `(x, y, authenticatorData, clientDataJSON, r, s)`; + `credId = keccak(abi.encode(1, x, y))`. + - If credential does not *authorize* `operation` ⇒ return `false`. + - If `authenticatorData.length < 37` ⇒ return `false`. + - If `flags & 0x01 == 0` (no user-present) ⇒ return `false`. + - If `flags & 0x04 == 0` (**no user-verified / biometric**) ⇒ return `false`. + - If `credential.rpIdHash ≠ 0` and `authenticatorData[0..31] ≠ credential.rpIdHash` ⇒ `false`. + - If `clientDataJSON` lacks `"type":"webauthn.get"` ⇒ return `false`. + - If `clientDataJSON` lacks `"challenge":"base64url(digest)"` ⇒ return `false`. + - Compute `message = H(authenticatorData ‖ H(clientDataJSON))`. + - If `P256Verify(message, r, s, x, y) ≠ 1` ⇒ return `false`. + - Else emit `Authorized(credId, operation, Passkey)`; return `true`. + +`operationData` (the raw ABI-encoded operation payload FWSS also forwards) is **not** consulted by +this authorizer — authorization is by `operation` typehash + registry scope. It is available for +subclasses that want content-level ACLs (e.g. metadata/path gating). + +### 6.1 What the digest binds (and what it doesn't) + +The `digest` is FWSS's EIP-712 operation digest. It cryptographically binds the operation to its +parameters (for AddPieces: `clientDataSetId, nonce, pieceData[], metadata` — including a per-payer +nonce). The passkey path additionally binds `digest` into the WebAuthn challenge, so a passkey +assertion cannot be re-pointed at a different operation. This authorizer keeps **no nonce of its +own**; replay protection for each operation is FWSS's (AddPieces: client nonce; Terminate: +terminal-state guard; SchedulePieceRemovals: delegated upstream — flagged for reviewers). + +--- + +## 7. Worked example — machine-key AddPieces envelope + +``` +x = 0xef23…79f2 # P256 pubkey X +y = 0xf1f3…089a # P256 pubkey Y +digest = +(r, s) = P256_sign(privkey, digest) # low-s normalized + +payload = abi.encode(x, y, r, s) # 4 × 32 bytes +signature = abi.encode(uint8(0), payload) # method 0 + payload +# FWSS calls: isAuthorized(dataSetId, payer, +# operation = 0x954bdc…d183 (ADD_PIECES_TYPEHASH), +# digest, signature, operationData) +``` + +Registration that makes it pass (owner tx): + +``` +addCredential(0 /*MachineP256*/, x, y, 0 /*rpIdHash n/a*/, 0 /*no expiry*/, + [0x954bdc…d183] /*AddPieces only*/) +``` + +--- + +## 8. Security notes for implementers + +- **Always low-`s` normalize** before sending; the precompile rejects high-`s`, which reads as an + auth failure. +- **UV is load-bearing** for the passkey method: it is the on-chain evidence that a human verified + (Touch ID / secure enclave). A client that requests a non-verifying assertion (`userVerification` + ≠ `required`) will be rejected (`flags & 0x04 == 0`). +- **rpIdHash pinning:** set a non-zero `rpIdHash` on passkey credentials to bind them to a specific + relying-party origin; `0` accepts any origin and should be used only for testing. +- **Revocation is on-chain and immediate:** `setCredentialEnabled(credId, false)` disables; + `removeCredential(credId, ops[])` deletes the entry *and* its `allowedOp` slots (bounded storage). +- The signature envelope proves authentication only — never treat a valid signature as + authorization without the registry check. diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol new file mode 100644 index 00000000..a3d4bb01 --- /dev/null +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +// Reference authorizer for the optional per-data-set write ACL (PR #536). Deployable standalone or +// as an EIP-1167 minimal-proxy clone; built here with the repo's deterministic profile +// (solc 0.8.30, via_ir, optimizer_runs=200, bytecode_hash="none") so it has a stable code identity +// for SP allowlisting. See MultiMethodAuthorizer.md for the wire-format spec. +// +// The IDataSetAuthorizer interface is inlined so this compiles before #536 merges. Once #536 lands, +// replace this inline copy with an import of the canonical `src/interfaces/IDataSetAuthorizer.sol`. + +/// filecoin-services PR #536 IDataSetAuthorizer (state-mutating CALL). +interface IDataSetAuthorizer { + function isAuthorized( + uint256 dataSetId, + address payer, + bytes32 operation, + bytes32 digest, + bytes calldata signature, + bytes calldata operationData + ) external returns (bool authorized); +} + +/// @title MultiMethodAuthorizer +/// @notice One authorizer that recognises TWO legitimate delegation paths for the SAME P256 +/// primitive (both verified via the 0x100 secp256r1 precompile), and dispatches on the +/// method tag carried in `signature`: +/// +/// method 0 MachineP256 — a raw key signs the operation digest directly. +/// Delegation to a machine agent / session key ("something it has"). +/// method 1 Passkey — a WebAuthn assertion (Touch ID / secure enclave) whose CHALLENGE +/// is the operation digest; requires the user-verified (biometric) +/// flag. Delegation to a human on a device ("you are + you have"). +/// +/// The owner keeps a registry of credentials. Each credential declares its method, its P256 +/// public key, and exactly which operations it may authorize — so e.g. a machine agent can +/// AddPieces while only your passkey may Terminate. The `signature` blob is: +/// +/// abi.encode(uint8 method, bytes payload) +/// machine payload = abi.encode(uint256 x, uint256 y, bytes32 r, bytes32 s) +/// passkey payload = abi.encode(uint256 x, uint256 y, bytes authenticatorData, +/// string clientDataJSON, bytes32 r, bytes32 s) +/// +/// Replay is handled by FWSS itself (the digest is operation-unique and FWSS enforces its own +/// nonces / termination state), so this authorizer stays a pure authenticate-and-authorize gate. +contract MultiMethodAuthorizer is IDataSetAuthorizer { + enum Method { MachineP256, Passkey } + + struct Credential { + Method method; + uint256 pubKeyX; + uint256 pubKeyY; + bytes32 rpIdHash; // Passkey only: expected RP-ID hash (0 = accept any origin) + uint64 expiry; // 0 = no expiry + bool enabled; + } + + /// The on-chain secp256r1 verifier, hardwired to the 0x100 precompile. Kept a `constant` (not a + /// constructor immutable) so every deployment has identical runtime bytecode — a stable code + /// identity for SP allowlisting — and the verifier can never be pointed at an attacker-controlled + /// contract. A precompile-less-chain fallback would be a separate, separately-audited contract, + /// not a per-deployer knob. + address public constant P256_VERIFIER = address(0x100); + address public owner; + + mapping(bytes32 credId => Credential) public credentials; + mapping(bytes32 credId => mapping(bytes32 operation => bool)) public allowedOp; + + event OwnershipTransferred(address indexed from, address indexed to); + event CredentialSet(bytes32 indexed credId, Method method, uint256 pubKeyX, uint256 pubKeyY); + event CredentialEnabled(bytes32 indexed credId, bool enabled); + event OperationAllowed(bytes32 indexed credId, bytes32 indexed operation, bool allowed); + event Authorized(bytes32 indexed credId, bytes32 indexed operation, Method method); + event CredentialRemoved(bytes32 indexed credId); + + error NotOwner(); + error UnknownMethod(uint8 method); + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + /// Standalone deploys set the owner here. Constructor logic lives in creation code, not runtime + /// code, so it does not affect the runtime bytecode / code identity used for SP allowlisting. + constructor() { + owner = msg.sender; + emit OwnershipTransferred(address(0), msg.sender); + } + + /// One-shot initializer for EIP-1167 minimal-proxy clones, which never run the constructor (so + /// their `owner` starts at zero). No-op / reverts once set, so a standalone deploy can't be + /// re-initialized. Deploy and initialize a clone atomically (factory/script) so a fresh clone + /// can't be initialize-front-run. + function initialize(address initialOwner) external { + require(owner == address(0), "already initialized"); + require(initialOwner != address(0), "zero owner"); + owner = initialOwner; + emit OwnershipTransferred(address(0), initialOwner); + } + + // ───────────────────────── owner: registry management ───────────────────────── + + /// Register (or overwrite) a credential and the operations it may authorize. + function addCredential( + Method method, + uint256 pubKeyX, + uint256 pubKeyY, + bytes32 rpIdHash, + uint64 expiry, + bytes32[] calldata ops + ) external onlyOwner returns (bytes32 credId) { + credId = credentialId(method, pubKeyX, pubKeyY); + credentials[credId] = + Credential({method: method, pubKeyX: pubKeyX, pubKeyY: pubKeyY, rpIdHash: rpIdHash, expiry: expiry, enabled: true}); + emit CredentialSet(credId, method, pubKeyX, pubKeyY); + emit CredentialEnabled(credId, true); + for (uint256 i = 0; i < ops.length; i++) { + allowedOp[credId][ops[i]] = true; + emit OperationAllowed(credId, ops[i], true); + } + } + + function setOperationAllowed(bytes32 credId, bytes32 operation, bool allowed) external onlyOwner { + allowedOp[credId][operation] = allowed; + emit OperationAllowed(credId, operation, allowed); + } + + function setCredentialEnabled(bytes32 credId, bool enabled) external onlyOwner { + credentials[credId].enabled = enabled; + emit CredentialEnabled(credId, enabled); + } + + function setCredentialExpiry(bytes32 credId, uint64 expiry) external onlyOwner { + credentials[credId].expiry = expiry; + } + + /// Fully remove a credential and reclaim its storage (no unbounded growth). Pass the operations + /// the credential was granted so their `allowedOp` slots are cleared too; unknown/extra ops are + /// harmless no-ops. (Disabling via setCredentialEnabled(false) also stops it authorizing, but + /// leaves the entry in storage — this deletes it.) + function removeCredential(bytes32 credId, bytes32[] calldata ops) external onlyOwner { + delete credentials[credId]; + for (uint256 i = 0; i < ops.length; i++) { + delete allowedOp[credId][ops[i]]; + } + emit CredentialRemoved(credId); + } + + function transferOwnership(address to) external onlyOwner { + emit OwnershipTransferred(owner, to); + owner = to; + } + + function credentialId(Method method, uint256 x, uint256 y) public pure returns (bytes32) { + return keccak256(abi.encode(method, x, y)); + } + + /// Helper for clients: base64url(challenge) as it must appear in WebAuthn clientDataJSON. + function encodeChallenge(bytes32 challenge) external pure returns (string memory) { + return _b64url(abi.encodePacked(challenge)); + } + + // ───────────────────────────── authorization ───────────────────────────── + + /// @inheritdoc IDataSetAuthorizer + function isAuthorized(uint256, address, bytes32 operation, bytes32 digest, bytes calldata signature, bytes calldata) + external + returns (bool) + { + (uint8 method, bytes memory payload) = abi.decode(signature, (uint8, bytes)); + if (method == uint8(Method.MachineP256)) return _machine(operation, digest, payload); + if (method == uint8(Method.Passkey)) return _passkey(operation, digest, payload); + revert UnknownMethod(method); // malformed → revert (bubbles); in-scope failures → false + } + + /// method 0 — machine key signs the FWSS digest directly. + function _machine(bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + (uint256 x, uint256 y, bytes32 r, bytes32 s) = abi.decode(payload, (uint256, uint256, bytes32, bytes32)); + bytes32 credId = credentialId(Method.MachineP256, x, y); + if (!_credentialAllows(credId, operation)) return false; + if (!_verifyP256(digest, r, s, x, y)) return false; + emit Authorized(credId, operation, Method.MachineP256); + return true; + } + + /// method 1 — WebAuthn passkey: verify presence+verification and that the assertion's + /// challenge is exactly the FWSS digest, then P256-verify the WebAuthn message. + function _passkey(bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + (uint256 x, uint256 y, bytes memory authData, string memory clientDataJSON, bytes32 r, bytes32 s) = + abi.decode(payload, (uint256, uint256, bytes, string, bytes32, bytes32)); + bytes32 credId = credentialId(Method.Passkey, x, y); + Credential storage c = credentials[credId]; + if (!_credentialAllows(credId, operation)) return false; + + // authenticatorData: rpIdHash(32) | flags(1) | signCount(4) | ... + if (authData.length < 37) return false; + uint8 flags = uint8(authData[32]); + if (flags & 0x01 == 0) return false; // UP: user present + if (flags & 0x04 == 0) return false; // UV: user VERIFIED (biometric) — what makes this "a human" + if (c.rpIdHash != bytes32(0)) { + bytes32 rp; + assembly { + rp := mload(add(authData, 32)) + } // first 32 bytes of authData + if (rp != c.rpIdHash) return false; + } + + // challenge binding: clientDataJSON must be a webauthn.get whose challenge == base64url(digest) + bytes memory cd = bytes(clientDataJSON); + if (!_contains(cd, bytes('"type":"webauthn.get"'))) return false; + if (!_contains(cd, abi.encodePacked('"challenge":"', _b64url(abi.encodePacked(digest)), '"'))) return false; + + // WebAuthn signed message = sha256(authenticatorData || sha256(clientDataJSON)) + bytes32 message = sha256(abi.encodePacked(authData, sha256(cd))); + if (!_verifyP256(message, r, s, x, y)) return false; + emit Authorized(credId, operation, Method.Passkey); + return true; + } + + function _credentialAllows(bytes32 credId, bytes32 operation) internal view returns (bool) { + Credential storage c = credentials[credId]; + if (!c.enabled) return false; + if (c.expiry != 0 && block.timestamp > c.expiry) return false; + return allowedOp[credId][operation]; + } + + function _verifyP256(bytes32 hash, bytes32 r, bytes32 s, uint256 x, uint256 y) internal view returns (bool) { + bytes memory input = abi.encodePacked(hash, r, s, bytes32(x), bytes32(y)); + (bool ok, bytes memory out) = P256_VERIFIER.staticcall(input); + return ok && out.length == 32 && bytes32(out) == bytes32(uint256(1)); + } + + // ───────────────────────────── small utils ───────────────────────────── + + /// base64url (RFC 4648 §5), no padding — WebAuthn challenge encoding. + function _b64url(bytes memory data) internal pure returns (string memory) { + bytes memory T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + uint256 len = data.length; + if (len == 0) return ""; + bytes memory out = new bytes((len * 8 + 5) / 6); + uint256 i; + uint256 j; + unchecked { + while (i + 3 <= len) { + uint256 n = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i + 1])) << 8) | uint8(data[i + 2]); + out[j++] = T[(n >> 18) & 63]; + out[j++] = T[(n >> 12) & 63]; + out[j++] = T[(n >> 6) & 63]; + out[j++] = T[n & 63]; + i += 3; + } + uint256 rem = len - i; + if (rem == 1) { + uint256 n = uint256(uint8(data[i])) << 16; + out[j++] = T[(n >> 18) & 63]; + out[j++] = T[(n >> 12) & 63]; + } else if (rem == 2) { + uint256 n = (uint256(uint8(data[i])) << 16) | (uint256(uint8(data[i + 1])) << 8); + out[j++] = T[(n >> 18) & 63]; + out[j++] = T[(n >> 12) & 63]; + out[j++] = T[(n >> 6) & 63]; + } + } + return string(out); + } + + function _contains(bytes memory hay, bytes memory needle) internal pure returns (bool) { + uint256 n = needle.length; + if (n == 0) return true; + if (n > hay.length) return false; + for (uint256 i = 0; i <= hay.length - n; i++) { + bool m = true; + for (uint256 k = 0; k < n; k++) { + if (hay[i + k] != needle[k]) { + m = false; + break; + } + } + if (m) return true; + } + return false; + } +} From c7a1fac13129681cb893bbc7b1400e1e70b8ebca Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 11:11:50 +0100 Subject: [PATCH 2/8] Tidy ip datasetId handling and add a wildcard option --- .../src/examples/MultiMethodAuthorizer.md | 85 +++++++++++------ .../src/examples/MultiMethodAuthorizer.sol | 93 +++++++++++++------ 2 files changed, 126 insertions(+), 52 deletions(-) diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md index cb76d2f3..b80e2ac5 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.md +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -4,7 +4,7 @@ Formal description of the wire format ("UCAN"-style delegation envelope) and the registry entries consumed by `MultiMethodAuthorizer`, an `IDataSetAuthorizer` for filecoin-services PR #536. This document is normative for anyone building a client that signs for this authorizer or a contract that interoperates with it. Source of truth: -[`src/MultiMethodAuthorizer.sol`](src/MultiMethodAuthorizer.sol). +[`MultiMethodAuthorizer.sol`](MultiMethodAuthorizer.sol). --- @@ -15,9 +15,18 @@ The authorizer splits a delegated authorization into two halves: - **Authentication** travels in the *signature envelope* that FWSS forwards to `isAuthorized` (Sections 4–5). It proves possession of a P256 key — and, for the passkey method, proves a human was *present and verified* (biometric) at signing time. It carries **no policy**. -- **Authorization** ("what may this key do") lives in the **on-chain credential registry** - (Section 3), managed by the data set's owner. Each credential enumerates exactly which - operations it may authorize, plus an optional expiry and an enable/disable switch. +- **Authorization** ("what may this key do, on which data set") lives in the **on-chain + credential registry** (Section 3), managed by the authorizer's owner. Each credential names + a data set (`WILDCARD_DATASET` = every data set this authorizer is attached to) and + enumerates exactly which operations it may authorize, plus an optional expiry and an + enable/disable switch. + +**Deployment model:** one clone (or standalone deploy) **per client**, not per data set. The +client attaches that same address as authorizer on each FWSS data set they want it to govern, +then scopes grants in the registry. Wildcard credentials do **not** auto-attach the authorizer +to data sets — FWSS attachment is still per data set; wildcard only means "once attached, this +key may act." This is the cheap on-chain analogue of the session-key registry (one P256 grant +across all the client's data sets) while still allowing per-data-set grants on the same clone. ### 1.1 Relationship to UCAN @@ -26,9 +35,9 @@ capability and caveats are held **on-chain**, not inside a signed token. Mapping | UCAN concept | Here | |---|---| -| Issuer (`iss`) | The data set **owner** (payer) who calls `addCredential` | -| Audience (`aud`) | The registered **credential** (a P256 public key `(x, y)`) | -| Capability (`can` / resource) | An `(operation, dataSetId)` pair — `allowedOp[credId][operation]` on-chain | +| Issuer (`iss`) | The authorizer **owner** (the client) who calls `addCredential` | +| Audience (`aud`) | The registered **credential** (a P256 public key `(x, y)` scoped to a `dataSetId`) | +| Capability (`can` / resource) | An `(operation, dataSetId)` pair — `allowedOp[credId][operation]` plus the credential's `dataSetId` (or `WILDCARD_DATASET`) | | Caveats | `expiry`, `enabled`, and (passkey) `rpIdHash` + user-verification requirement | | Proof / invocation signature | The P256 signature over the FWSS operation `digest` (Section 6) | @@ -63,25 +72,32 @@ operation digest itself. Replay/ordering is **not** in this envelope; it is FWSS ```solidity enum Method { MachineP256 /*0*/, Passkey /*1*/ } +uint256 constant WILDCARD_DATASET = type(uint256).max; // all attached data sets + struct Credential { - Method method; // 0 = machine key, 1 = WebAuthn passkey - uint256 pubKeyX; // P256 public key X - uint256 pubKeyY; // P256 public key Y - bytes32 rpIdHash; // Passkey only: expected SHA-256(rpId); 0 = accept any origin - uint64 expiry; // unix seconds; 0 = no expiry - bool enabled; // owner kill-switch + Method method; // 0 = machine key, 1 = WebAuthn passkey + uint256 pubKeyX; // P256 public key X + uint256 pubKeyY; // P256 public key Y + uint256 dataSetId; // specific FWSS data set, or WILDCARD_DATASET + bytes32 rpIdHash; // Passkey only: expected SHA-256(rpId); 0 = accept any origin + uint64 expiry; // unix seconds; 0 = no expiry + bool enabled; // owner kill-switch } mapping(bytes32 credId => Credential) credentials; mapping(bytes32 credId => mapping(bytes32 operation => bool)) allowedOp; ``` -**Credential identifier** (deterministic, collision-resistant per method+key): +**Credential identifier** (deterministic, collision-resistant per method+key+data set): ``` -credId = keccak(abi.encode(Method method, uint256 x, uint256 y)) +credId = keccak(abi.encode(Method method, uint256 x, uint256 y, uint256 dataSetId)) ``` +The same P256 key may therefore be registered more than once — e.g. AddPieces-only on data set +7, and a separate wildcard credential for Terminate on every attached data set. Those are two +`credId`s. + **Operation identifiers** — the FWSS EIP-712 struct type-hashes (the `operation` argument of `isAuthorized` and the key of `allowedOp`): @@ -92,12 +108,20 @@ credId = keccak(abi.encode(Method method, uint256 x, uint256 y)) | TerminateService | `0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc` | **Owner API** (only the authorizer's `owner`; see PLAYBOOK for cast invocations): -`addCredential(method, x, y, rpIdHash, expiry, ops[])`, `setOperationAllowed`, +`addCredential(method, x, y, dataSetId, rpIdHash, expiry, ops[])`, `setOperationAllowed`, `setCredentialEnabled`, `setCredentialExpiry`, `removeCredential(credId, ops[])`, `transferOwnership`. -A credential **authorizes** `operation` iff: -`enabled ∧ (expiry == 0 ∨ block.timestamp ≤ expiry) ∧ allowedOp[credId][operation]`. +A credential **authorizes** `(operation, dataSetId)` iff: +`enabled ∧ (expiry == 0 ∨ block.timestamp ≤ expiry) ∧ allowedOp[credId][operation]` +**and** the credential's `dataSetId` is either the requested data set or `WILDCARD_DATASET`. + +Lookup in `isAuthorized` prefers the **specific** credential `(method, x, y, dataSetId)` and, if +that does not currently authorize the operation, falls back to the **wildcard** credential +`(method, x, y, WILDCARD_DATASET)`. The two grants are a union: a wildcard AddPieces still +authorizes AddPieces on data set 7 even if a specific credential for data set 7 exists but +does not list AddPieces. To deny a key on one data set while keeping a wildcard, remove the +wildcard and register per-data-set credentials instead. ### 3.1 `expiry` and time on FEVM @@ -152,7 +176,7 @@ payload = abi.encode(uint256 x, uint256 y, bytes32 r, bytes32 s) | Field | Type | Meaning | |---|---|---| -| `x`, `y` | `uint256` | P256 public key; selects the credential `credId = keccak(abi.encode(0, x, y))` | +| `x`, `y` | `uint256` | P256 public key; selects `credId = keccak(abi.encode(0, x, y, dataSetId))` (then wildcard fallback) | | `r`, `s` | `bytes32` | P256 signature over the FWSS `digest` (low-`s`) | Signed message = the FWSS `digest` **verbatim**. @@ -165,7 +189,7 @@ payload = abi.encode(uint256 x, uint256 y, bytes authenticatorData, string clien | Field | Type | Meaning | |---|---|---| -| `x`, `y` | `uint256` | P256 public key; selects `credId = keccak(abi.encode(1, x, y))` | +| `x`, `y` | `uint256` | P256 public key; selects `credId = keccak(abi.encode(1, x, y, dataSetId))` (then wildcard fallback) | | `authenticatorData` | `bytes` | WebAuthn authenticator data (≥ 37 bytes) | | `clientDataJSON` | `string` | WebAuthn client data JSON | | `r`, `s` | `bytes32` | P256 signature over the WebAuthn **message** (below), low-`s` | @@ -203,13 +227,14 @@ message = H( authenticatorData ‖ H(clientDataJSON) ) `isAuthorized(dataSetId, payer, operation, digest, signature, operationData)`: 1. Decode the envelope → `(method, payload)`. Unknown `method` ⇒ **revert**. -2. **Machine (0):** decode `(x, y, r, s)`; `credId = keccak(abi.encode(0, x, y))`. - - If credential does not *authorize* `operation` (Section 3) ⇒ return `false`. +2. Resolve the credential: specific `credId = keccak(abi.encode(method, x, y, dataSetId))` if it + currently authorizes `operation` (Section 3); otherwise the wildcard + `credId = keccak(abi.encode(method, x, y, WILDCARD_DATASET))`. If neither authorizes ⇒ `false`. + (`payer` is not consulted — FWSS attachment is what bound this authorizer to the data set.) +3. **Machine (0):** decode `(x, y, r, s)`. - If `P256Verify(digest, r, s, x, y) ≠ 1` ⇒ return `false`. - Else emit `Authorized(credId, operation, MachineP256)`; return `true`. -3. **Passkey (1):** decode `(x, y, authenticatorData, clientDataJSON, r, s)`; - `credId = keccak(abi.encode(1, x, y))`. - - If credential does not *authorize* `operation` ⇒ return `false`. +4. **Passkey (1):** decode `(x, y, authenticatorData, clientDataJSON, r, s)`. - If `authenticatorData.length < 37` ⇒ return `false`. - If `flags & 0x01 == 0` (no user-present) ⇒ return `false`. - If `flags & 0x04 == 0` (**no user-verified / biometric**) ⇒ return `false`. @@ -253,8 +278,11 @@ signature = abi.encode(uint8(0), payload) # method 0 + payload Registration that makes it pass (owner tx): ``` -addCredential(0 /*MachineP256*/, x, y, 0 /*rpIdHash n/a*/, 0 /*no expiry*/, +addCredential(0 /*MachineP256*/, x, y, dataSetId, 0 /*rpIdHash n/a*/, 0 /*no expiry*/, [0x954bdc…d183] /*AddPieces only*/) +# session-key equivalent — same key, every attached data set: +addCredential(0 /*MachineP256*/, x, y, type(uint256).max /*WILDCARD_DATASET*/, 0, 0, + [0x954bdc…d183]) ``` --- @@ -270,5 +298,10 @@ addCredential(0 /*MachineP256*/, x, y, 0 /*rpIdHash n/a*/, 0 /*no expiry*/, relying-party origin; `0` accepts any origin and should be used only for testing. - **Revocation is on-chain and immediate:** `setCredentialEnabled(credId, false)` disables; `removeCredential(credId, ops[])` deletes the entry *and* its `allowedOp` slots (bounded storage). +- **Wildcard is a union, not a default-deny overlay.** A wildcard credential still authorizes on + a data set that also has a more specific credential for the same key. There is no per-data-set + exception list — restrict a key by dropping the wildcard and issuing specific grants. +- **Attachment is still per data set.** `WILDCARD_DATASET` does not make the authorizer apply to + data sets the payer has not attached it to. - The signature envelope proves authentication only — never treat a valid signature as authorization without the registry check. diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index a3d4bb01..ddad7408 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.21; -// Reference authorizer for the optional per-data-set write ACL (PR #536). Deployable standalone or -// as an EIP-1167 minimal-proxy clone; built here with the repo's deterministic profile -// (solc 0.8.30, via_ir, optimizer_runs=200, bytecode_hash="none") so it has a stable code identity -// for SP allowlisting. See MultiMethodAuthorizer.md for the wire-format spec. +// Reference authorizer for the optional per-data-set write ACL (PR #536). One clone (or standalone +// deploy) per client: the payer attaches the same address to each of their data sets and scopes +// credentials by dataSetId (WILDCARD_DATASET = all of them). Built with the repo's deterministic +// profile (solc 0.8.30, via_ir, optimizer_runs=200, bytecode_hash="none") so it has a stable code +// identity for SP allowlisting. See MultiMethodAuthorizer.md for the wire-format spec. // // The IDataSetAuthorizer interface is inlined so this compiles before #536 merges. Once #536 lands, // replace this inline copy with an import of the canonical `src/interfaces/IDataSetAuthorizer.sol`. @@ -33,8 +34,11 @@ interface IDataSetAuthorizer { /// flag. Delegation to a human on a device ("you are + you have"). /// /// The owner keeps a registry of credentials. Each credential declares its method, its P256 -/// public key, and exactly which operations it may authorize — so e.g. a machine agent can -/// AddPieces while only your passkey may Terminate. The `signature` blob is: +/// public key, the data set it applies to (`WILDCARD_DATASET` = every data set this +/// authorizer is attached to), and exactly which operations it may authorize — so e.g. a +/// machine agent can AddPieces on one data set while only your passkey may Terminate, or a +/// single P256 key can act across all data sets like the session-key registry. The +/// `signature` blob is: /// /// abi.encode(uint8 method, bytes payload) /// machine payload = abi.encode(uint256 x, uint256 y, bytes32 r, bytes32 s) @@ -50,11 +54,16 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { Method method; uint256 pubKeyX; uint256 pubKeyY; + uint256 dataSetId; // specific data set, or WILDCARD_DATASET for every attached data set bytes32 rpIdHash; // Passkey only: expected RP-ID hash (0 = accept any origin) uint64 expiry; // 0 = no expiry bool enabled; } + /// Sentinel dataSetId: credential applies to every data set this authorizer is attached to. + /// Chosen as type(uint256).max so it cannot collide with a real FWSS data set id. + uint256 public constant WILDCARD_DATASET = type(uint256).max; + /// The on-chain secp256r1 verifier, hardwired to the 0x100 precompile. Kept a `constant` (not a /// constructor immutable) so every deployment has identical runtime bytecode — a stable code /// identity for SP allowlisting — and the verifier can never be pointed at an attacker-controlled @@ -67,7 +76,7 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { mapping(bytes32 credId => mapping(bytes32 operation => bool)) public allowedOp; event OwnershipTransferred(address indexed from, address indexed to); - event CredentialSet(bytes32 indexed credId, Method method, uint256 pubKeyX, uint256 pubKeyY); + event CredentialSet(bytes32 indexed credId, Method method, uint256 pubKeyX, uint256 pubKeyY, uint256 dataSetId); event CredentialEnabled(bytes32 indexed credId, bool enabled); event OperationAllowed(bytes32 indexed credId, bytes32 indexed operation, bool allowed); event Authorized(bytes32 indexed credId, bytes32 indexed operation, Method method); @@ -89,7 +98,9 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { } /// One-shot initializer for EIP-1167 minimal-proxy clones, which never run the constructor (so - /// their `owner` starts at zero). No-op / reverts once set, so a standalone deploy can't be + /// their `owner` starts at zero). Intended model: one clone per client, initialized to that + /// client's owner address; the client then attaches this same address as authorizer on each + /// data set they want it to govern. Reverts once set, so a standalone deploy can't be /// re-initialized. Deploy and initialize a clone atomically (factory/script) so a fresh clone /// can't be initialize-front-run. function initialize(address initialOwner) external { @@ -102,18 +113,28 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { // ───────────────────────── owner: registry management ───────────────────────── /// Register (or overwrite) a credential and the operations it may authorize. + /// `dataSetId` is a specific FWSS data set, or `WILDCARD_DATASET` to authorize on every data + /// set this authorizer is attached to (session-key-registry equivalent for P256). function addCredential( Method method, uint256 pubKeyX, uint256 pubKeyY, + uint256 dataSetId, bytes32 rpIdHash, uint64 expiry, bytes32[] calldata ops ) external onlyOwner returns (bytes32 credId) { - credId = credentialId(method, pubKeyX, pubKeyY); - credentials[credId] = - Credential({method: method, pubKeyX: pubKeyX, pubKeyY: pubKeyY, rpIdHash: rpIdHash, expiry: expiry, enabled: true}); - emit CredentialSet(credId, method, pubKeyX, pubKeyY); + credId = credentialId(method, pubKeyX, pubKeyY, dataSetId); + credentials[credId] = Credential({ + method: method, + pubKeyX: pubKeyX, + pubKeyY: pubKeyY, + dataSetId: dataSetId, + rpIdHash: rpIdHash, + expiry: expiry, + enabled: true + }); + emit CredentialSet(credId, method, pubKeyX, pubKeyY, dataSetId); emit CredentialEnabled(credId, true); for (uint256 i = 0; i < ops.length; i++) { allowedOp[credId][ops[i]] = true; @@ -152,8 +173,8 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { owner = to; } - function credentialId(Method method, uint256 x, uint256 y) public pure returns (bytes32) { - return keccak256(abi.encode(method, x, y)); + function credentialId(Method method, uint256 x, uint256 y, uint256 dataSetId) public pure returns (bytes32) { + return keccak256(abi.encode(method, x, y, dataSetId)); } /// Helper for clients: base64url(challenge) as it must appear in WebAuthn clientDataJSON. @@ -164,21 +185,41 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { // ───────────────────────────── authorization ───────────────────────────── /// @inheritdoc IDataSetAuthorizer - function isAuthorized(uint256, address, bytes32 operation, bytes32 digest, bytes calldata signature, bytes calldata) - external - returns (bool) - { + function isAuthorized( + uint256 dataSetId, + address, + bytes32 operation, + bytes32 digest, + bytes calldata signature, + bytes calldata + ) external returns (bool) { (uint8 method, bytes memory payload) = abi.decode(signature, (uint8, bytes)); - if (method == uint8(Method.MachineP256)) return _machine(operation, digest, payload); - if (method == uint8(Method.Passkey)) return _passkey(operation, digest, payload); + if (method == uint8(Method.MachineP256)) return _machine(dataSetId, operation, digest, payload); + if (method == uint8(Method.Passkey)) return _passkey(dataSetId, operation, digest, payload); revert UnknownMethod(method); // malformed → revert (bubbles); in-scope failures → false } + /// Resolve a credential for this key on `dataSetId`, falling back to the wildcard credential. + /// Returns the matching credId (specific preferred) and whether it currently authorizes `operation`. + function _lookupCred(Method method, uint256 x, uint256 y, uint256 dataSetId, bytes32 operation) + internal + view + returns (bytes32 credId, bool allowed) + { + credId = credentialId(method, x, y, dataSetId); + if (_credentialAllows(credId, operation)) return (credId, true); + if (dataSetId != WILDCARD_DATASET) { + credId = credentialId(method, x, y, WILDCARD_DATASET); + if (_credentialAllows(credId, operation)) return (credId, true); + } + return (credId, false); + } + /// method 0 — machine key signs the FWSS digest directly. - function _machine(bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + function _machine(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { (uint256 x, uint256 y, bytes32 r, bytes32 s) = abi.decode(payload, (uint256, uint256, bytes32, bytes32)); - bytes32 credId = credentialId(Method.MachineP256, x, y); - if (!_credentialAllows(credId, operation)) return false; + (bytes32 credId, bool allowed) = _lookupCred(Method.MachineP256, x, y, dataSetId, operation); + if (!allowed) return false; if (!_verifyP256(digest, r, s, x, y)) return false; emit Authorized(credId, operation, Method.MachineP256); return true; @@ -186,12 +227,12 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { /// method 1 — WebAuthn passkey: verify presence+verification and that the assertion's /// challenge is exactly the FWSS digest, then P256-verify the WebAuthn message. - function _passkey(bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + function _passkey(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { (uint256 x, uint256 y, bytes memory authData, string memory clientDataJSON, bytes32 r, bytes32 s) = abi.decode(payload, (uint256, uint256, bytes, string, bytes32, bytes32)); - bytes32 credId = credentialId(Method.Passkey, x, y); + (bytes32 credId, bool allowed) = _lookupCred(Method.Passkey, x, y, dataSetId, operation); + if (!allowed) return false; Credential storage c = credentials[credId]; - if (!_credentialAllows(credId, operation)) return false; // authenticatorData: rpIdHash(32) | flags(1) | signCount(4) | ... if (authData.length < 37) return false; From 741d4f09b193623202afb493a64bb1f899f4d778 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 11:49:03 +0100 Subject: [PATCH 3/8] Add tests and fix a bug surfaced by the tests --- .../src/examples/MultiMethodAuthorizer.md | 13 +- .../src/examples/MultiMethodAuthorizer.sol | 83 +++- .../test/MultiMethodAuthorizer.t.sol | 443 ++++++++++++++++++ 3 files changed, 515 insertions(+), 24 deletions(-) create mode 100644 service_contracts/test/MultiMethodAuthorizer.t.sol diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md index b80e2ac5..5f65359d 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.md +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -82,6 +82,7 @@ struct Credential { bytes32 rpIdHash; // Passkey only: expected SHA-256(rpId); 0 = accept any origin uint64 expiry; // unix seconds; 0 = no expiry bool enabled; // owner kill-switch + bytes32[] ops; // live grant list (source of truth for remove / replace) } mapping(bytes32 credId => Credential) credentials; @@ -108,9 +109,11 @@ The same P256 key may therefore be registered more than once — e.g. AddPieces- | TerminateService | `0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc` | **Owner API** (only the authorizer's `owner`; see PLAYBOOK for cast invocations): -`addCredential(method, x, y, dataSetId, rpIdHash, expiry, ops[])`, `setOperationAllowed`, -`setCredentialEnabled`, `setCredentialExpiry`, `removeCredential(credId, ops[])`, -`transferOwnership`. +`addCredential(method, x, y, dataSetId, rpIdHash, expiry, ops[])` — registers, or **replaces** +the grant set if the same `(method, key, dataSetId)` already exists; +`setOperationAllowed(credId, operation, allowed)` — the only incremental permission edit; +`setCredentialEnabled`, `setCredentialExpiry`, `removeCredential(credId)` — wipes the credential +*and* every granted op (callers do not list ops); `transferOwnership`. A credential **authorizes** `(operation, dataSetId)` iff: `enabled ∧ (expiry == 0 ∨ block.timestamp ≤ expiry) ∧ allowedOp[credId][operation]` @@ -297,7 +300,9 @@ addCredential(0 /*MachineP256*/, x, y, type(uint256).max /*WILDCARD_DATASET*/, 0 - **rpIdHash pinning:** set a non-zero `rpIdHash` on passkey credentials to bind them to a specific relying-party origin; `0` accepts any origin and should be used only for testing. - **Revocation is on-chain and immediate:** `setCredentialEnabled(credId, false)` disables; - `removeCredential(credId, ops[])` deletes the entry *and* its `allowedOp` slots (bounded storage). + `removeCredential(credId)` deletes the entry *and* every `allowedOp` slot on its grant list. + Re-adding the same key starts from a clean grant set. `addCredential` replace is also a full + replace — it does not union with leftover ops. - **Wildcard is a union, not a default-deny overlay.** A wildcard credential still authorizes on a data set that also has a more specific credential for the same key. There is no per-data-set exception list — restrict a key by dropping the wildcard and issuing specific grants. diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index ddad7408..68c8a256 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -58,6 +58,7 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { bytes32 rpIdHash; // Passkey only: expected RP-ID hash (0 = accept any origin) uint64 expiry; // 0 = no expiry bool enabled; + bytes32[] ops; // live grant list; addCredential replaces it, removeCredential wipes it } /// Sentinel dataSetId: credential applies to every data set this authorizer is attached to. @@ -84,6 +85,7 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { error NotOwner(); error UnknownMethod(uint8 method); + error UnknownCredential(bytes32 credId); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); @@ -112,7 +114,9 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { // ───────────────────────── owner: registry management ───────────────────────── - /// Register (or overwrite) a credential and the operations it may authorize. + /// Register a credential and the operations it may authorize. Re-adding the same + /// (method, key, dataSetId) **replaces** the previous grant set (old ops are cleared). + /// After that, use `setOperationAllowed` to add or remove individual operations. /// `dataSetId` is a specific FWSS data set, or `WILDCARD_DATASET` to authorize on every data /// set this authorizer is attached to (session-key-registry equivalent for P256). function addCredential( @@ -125,25 +129,29 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { bytes32[] calldata ops ) external onlyOwner returns (bytes32 credId) { credId = credentialId(method, pubKeyX, pubKeyY, dataSetId); - credentials[credId] = Credential({ - method: method, - pubKeyX: pubKeyX, - pubKeyY: pubKeyY, - dataSetId: dataSetId, - rpIdHash: rpIdHash, - expiry: expiry, - enabled: true - }); + _clearOps(credId); + Credential storage c = credentials[credId]; + c.method = method; + c.pubKeyX = pubKeyX; + c.pubKeyY = pubKeyY; + c.dataSetId = dataSetId; + c.rpIdHash = rpIdHash; + c.expiry = expiry; + c.enabled = true; emit CredentialSet(credId, method, pubKeyX, pubKeyY, dataSetId); emit CredentialEnabled(credId, true); for (uint256 i = 0; i < ops.length; i++) { - allowedOp[credId][ops[i]] = true; + _grantOp(credId, ops[i]); emit OperationAllowed(credId, ops[i], true); } } + /// Add or remove a single operation on an existing credential. The only incremental + /// permission API — `addCredential` replaces the whole grant set, `removeCredential` wipes it. function setOperationAllowed(bytes32 credId, bytes32 operation, bool allowed) external onlyOwner { - allowedOp[credId][operation] = allowed; + if (!_exists(credId)) revert UnknownCredential(credId); + if (allowed) _grantOp(credId, operation); + else _revokeOp(credId, operation); emit OperationAllowed(credId, operation, allowed); } @@ -156,18 +164,20 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { credentials[credId].expiry = expiry; } - /// Fully remove a credential and reclaim its storage (no unbounded growth). Pass the operations - /// the credential was granted so their `allowedOp` slots are cleared too; unknown/extra ops are - /// harmless no-ops. (Disabling via setCredentialEnabled(false) also stops it authorizing, but - /// leaves the entry in storage — this deletes it.) - function removeCredential(bytes32 credId, bytes32[] calldata ops) external onlyOwner { + /// Fully remove a credential and reclaim its storage, including every granted operation. + /// Callers do not list ops — the live grant list on the credential is the source of truth. + /// (Disabling via setCredentialEnabled(false) also stops it authorizing, but leaves the entry.) + function removeCredential(bytes32 credId) external onlyOwner { + _clearOps(credId); delete credentials[credId]; - for (uint256 i = 0; i < ops.length; i++) { - delete allowedOp[credId][ops[i]]; - } emit CredentialRemoved(credId); } + /// Current grant list for a credential (same set `removeCredential` will wipe). + function credentialOps(bytes32 credId) external view returns (bytes32[] memory) { + return credentials[credId].ops; + } + function transferOwnership(address to) external onlyOwner { emit OwnershipTransferred(owner, to); owner = to; @@ -259,6 +269,39 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { return true; } + function _exists(bytes32 credId) internal view returns (bool) { + Credential storage c = credentials[credId]; + return c.enabled || c.expiry != 0 || c.pubKeyX != 0 || c.pubKeyY != 0 || c.ops.length != 0; + } + + function _clearOps(bytes32 credId) internal { + bytes32[] storage existing = credentials[credId].ops; + for (uint256 i = 0; i < existing.length; i++) { + delete allowedOp[credId][existing[i]]; + } + delete credentials[credId].ops; + } + + function _grantOp(bytes32 credId, bytes32 operation) internal { + if (allowedOp[credId][operation]) return; + credentials[credId].ops.push(operation); + allowedOp[credId][operation] = true; + } + + function _revokeOp(bytes32 credId, bytes32 operation) internal { + if (!allowedOp[credId][operation]) return; + delete allowedOp[credId][operation]; + bytes32[] storage existing = credentials[credId].ops; + uint256 n = existing.length; + for (uint256 i = 0; i < n; i++) { + if (existing[i] == operation) { + existing[i] = existing[n - 1]; + existing.pop(); + return; + } + } + } + function _credentialAllows(bytes32 credId, bytes32 operation) internal view returns (bool) { Credential storage c = credentials[credId]; if (!c.enabled) return false; diff --git a/service_contracts/test/MultiMethodAuthorizer.t.sol b/service_contracts/test/MultiMethodAuthorizer.t.sol new file mode 100644 index 00000000..e68993c7 --- /dev/null +++ b/service_contracts/test/MultiMethodAuthorizer.t.sol @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol"; +import {MultiMethodAuthorizer} from "../src/examples/MultiMethodAuthorizer.sol"; + +/// RIP-7212-shaped shim so tests do not depend on a native 0x100 precompile. +contract Rip7212Shim { + fallback() external { + bytes32 hash; + bytes32 r; + bytes32 s; + bytes32 x; + bytes32 y; + assembly { + hash := calldataload(0) + r := calldataload(32) + s := calldataload(64) + x := calldataload(96) + y := calldataload(128) + } + bool ok = P256.verifySolidity(hash, r, s, x, y); + assembly { + mstore(0x00, ok) + return(0x00, 0x20) + } + } +} + +contract MultiMethodAuthorizerTest is Test { + using Clones for address; + + // Spec / FWSS operation typehashes. + bytes32 internal constant ADD_PIECES = 0x954bdc254591a7eab1b73f03842464d9283a08352772737094d710a4428fd183; + bytes32 internal constant SCHEDULE_REMOVALS = 0x5415701e313bb627e755b16924727217bb356574fe20e7061442c200b0822b22; + bytes32 internal constant TERMINATE = 0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc; + + uint256 internal constant PRIV = 0x2b6e033b015f3c86da0e6ecf6bd295fb19b45166d0ec219761d09c3d32225f6a; + bytes32 internal constant DIGEST = bytes32(uint256(0x1111)); + bytes32 internal constant RP_ID = keccak256("example.com"); // not sha256; tests pin an arbitrary 32-byte value + + MultiMethodAuthorizer internal auth; + uint256 internal px; + uint256 internal py; + address internal stranger; + + function setUp() public { + vm.etch(address(0x100), type(Rip7212Shim).runtimeCode); + auth = new MultiMethodAuthorizer(); + (px, py) = vm.publicKeyP256(PRIV); + stranger = makeAddr("stranger"); + } + + // ───────────────────────────── helpers ───────────────────────────── + + function _ops(bytes32 a) internal pure returns (bytes32[] memory o) { + o = new bytes32[](1); + o[0] = a; + } + + function _ops2(bytes32 a, bytes32 b) internal pure returns (bytes32[] memory o) { + o = new bytes32[](2); + o[0] = a; + o[1] = b; + } + + function _lowS(bytes32 s) internal pure returns (bytes32) { + if (uint256(s) > P256.N / 2) return bytes32(P256.N - uint256(s)); + return s; + } + + function _sign(bytes32 digest) internal pure returns (bytes32 r, bytes32 s) { + (r, s) = vm.signP256(PRIV, digest); + s = _lowS(s); + } + + function _machineBlob(bytes32 digest) internal view returns (bytes memory) { + (bytes32 r, bytes32 s) = _sign(digest); + return abi.encode(uint8(0), abi.encode(px, py, r, s)); + } + + function _authData(bytes32 rpIdHash, uint8 flags) internal pure returns (bytes memory ad) { + ad = new bytes(37); + assembly { + mstore(add(ad, 32), rpIdHash) + } + ad[32] = bytes1(flags); + } + + function _clientData(bytes32 digest, bool compact) internal view returns (string memory) { + string memory ch = auth.encodeChallenge(digest); + if (compact) { + return string.concat('{"type":"webauthn.get","challenge":"', ch, '","origin":"https://example.com"}'); + } + return string.concat('{"type": "webauthn.get","challenge": "', ch, '","origin": "https://example.com"}'); + } + + function _passkeyBlob(bytes memory ad, string memory cd) internal view returns (bytes memory) { + bytes32 message = sha256(abi.encodePacked(ad, sha256(bytes(cd)))); + (bytes32 r, bytes32 s) = _sign(message); + return abi.encode(uint8(1), abi.encode(px, py, ad, cd, r, s)); + } + + function _goodPasskey(bytes32 digest, bytes32 rpIdHash) internal view returns (bytes memory) { + bytes memory ad = _authData(rpIdHash, 0x05); // UP | UV + return _passkeyBlob(ad, _clientData(digest, true)); + } + + function _addMachine(uint256 dataSetId, bytes32[] memory ops) internal returns (bytes32) { + return auth.addCredential(MultiMethodAuthorizer.Method.MachineP256, px, py, dataSetId, bytes32(0), 0, ops); + } + + function _addPasskey(uint256 dataSetId, bytes32 rpIdHash, bytes32[] memory ops) internal returns (bytes32) { + return auth.addCredential(MultiMethodAuthorizer.Method.Passkey, px, py, dataSetId, rpIdHash, 0, ops); + } + + function _check(uint256 dataSetId, bytes32 operation, bytes memory signature) internal returns (bool) { + return auth.isAuthorized(dataSetId, address(0), operation, DIGEST, signature, ""); + } + + function _checkDigest(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory signature) + internal + returns (bool) + { + return auth.isAuthorized(dataSetId, address(0), operation, digest, signature, ""); + } + + // ───────────────────────────── spec canaries ───────────────────────────── + + function test_specOperationTypehashesMatchFwss() public pure { + assertEq( + ADD_PIECES, + keccak256( + abi.encodePacked( + "AddPieces(uint256 clientDataSetId,uint256 nonce,Cid[] pieceData,PieceMetadata[] pieceMetadata)", + "Cid(bytes data)", + "MetadataEntry(string key,string value)", + "PieceMetadata(uint256 pieceIndex,MetadataEntry[] metadata)" + ) + ) + ); + assertEq(SCHEDULE_REMOVALS, keccak256("SchedulePieceRemovals(uint256 clientDataSetId,uint256[] pieceIds)")); + assertEq(TERMINATE, keccak256("TerminateService(uint256 dataSetId)")); + } + + function test_encodeChallengeIsUnpaddedBase64UrlOf32Bytes() public view { + string memory ch = auth.encodeChallenge(DIGEST); + assertEq(bytes(ch).length, 43); + bytes memory raw = bytes(ch); + for (uint256 i; i < raw.length; i++) { + bytes1 c = raw[i]; + bool ok = (c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c == "-" || c == "_"; + assertTrue(ok, "non-base64url char"); + } + } + + // ───────────────────────────── registry / clone ───────────────────────────── + + function test_constructorSetsDeployerAsOwner() public view { + assertEq(auth.owner(), address(this)); + } + + function test_nonOwnerCannotMutateRegistry() public { + vm.startPrank(stranger); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + _addMachine(1, _ops(ADD_PIECES)); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + auth.setOperationAllowed(bytes32(0), ADD_PIECES, true); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + auth.setCredentialEnabled(bytes32(0), false); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + auth.setCredentialExpiry(bytes32(0), 1); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + auth.removeCredential(bytes32(0)); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + auth.transferOwnership(stranger); + vm.stopPrank(); + } + + function test_standaloneCannotBeReinitialized() public { + vm.expectRevert("already initialized"); + auth.initialize(stranger); + vm.expectRevert("already initialized"); + auth.initialize(address(0)); + } + + function test_cloneInitializeIsOneShotAndFrontRunnable() public { + MultiMethodAuthorizer clone = MultiMethodAuthorizer(address(auth).clone()); + assertEq(clone.owner(), address(0)); + + vm.prank(stranger); + clone.initialize(stranger); + assertEq(clone.owner(), stranger); + + vm.expectRevert("already initialized"); + clone.initialize(address(this)); + } + + function test_cloneInitializeRejectsZeroOwner() public { + MultiMethodAuthorizer clone = MultiMethodAuthorizer(address(auth).clone()); + vm.expectRevert("zero owner"); + clone.initialize(address(0)); + } + + function test_transferOwnershipToZeroLocksRegistry() public { + auth.transferOwnership(address(0)); + assertEq(auth.owner(), address(0)); + vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); + _addMachine(1, _ops(ADD_PIECES)); + } + + function test_addCredentialOverwriteReplacesOps() public { + bytes32 credId = _addMachine(7, _ops2(ADD_PIECES, TERMINATE)); + _addMachine(7, _ops(ADD_PIECES)); + assertTrue(auth.allowedOp(credId, ADD_PIECES)); + assertFalse(auth.allowedOp(credId, TERMINATE)); + assertFalse(_check(7, TERMINATE, _machineBlob(DIGEST))); + bytes32[] memory ops = auth.credentialOps(credId); + assertEq(ops.length, 1); + assertEq(ops[0], ADD_PIECES); + } + + function test_removeCredentialWipesAllOpsSoReaddDoesNotResurrect() public { + bytes32 credId = _addMachine(7, _ops2(ADD_PIECES, TERMINATE)); + auth.removeCredential(credId); + assertFalse(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + assertFalse(_check(7, TERMINATE, _machineBlob(DIGEST))); + + _addMachine(7, _ops(TERMINATE)); + assertTrue(_check(7, TERMINATE, _machineBlob(DIGEST))); + assertFalse(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + assertEq(auth.credentialOps(credId).length, 1); + } + + function test_setOperationAllowedFineTunesThenRemoveWipesThoseToo() public { + bytes32 credId = _addMachine(7, _ops(ADD_PIECES)); + auth.setOperationAllowed(credId, TERMINATE, true); + assertTrue(_check(7, TERMINATE, _machineBlob(DIGEST))); + assertEq(auth.credentialOps(credId).length, 2); + + auth.setOperationAllowed(credId, ADD_PIECES, false); + assertFalse(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + assertTrue(_check(7, TERMINATE, _machineBlob(DIGEST))); + assertEq(auth.credentialOps(credId).length, 1); + assertEq(auth.credentialOps(credId)[0], TERMINATE); + + auth.removeCredential(credId); + _addMachine(7, _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + assertFalse(_check(7, TERMINATE, _machineBlob(DIGEST))); + } + + function test_setOperationAllowedOnUnknownCredentialReverts() public { + bytes32 missing = auth.credentialId(MultiMethodAuthorizer.Method.MachineP256, px, py, 7); + vm.expectRevert(abi.encodeWithSelector(MultiMethodAuthorizer.UnknownCredential.selector, missing)); + auth.setOperationAllowed(missing, ADD_PIECES, true); + } + + // ───────────────────────────── dataset isolation + wildcard ───────────────────────────── + + function test_specificCredentialDoesNotAuthorizeOtherDataSet() public { + _addMachine(7, _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + assertFalse(_check(8, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_wildcardCredentialAuthorizesEveryDataSet() public { + _addMachine(auth.WILDCARD_DATASET(), _ops(ADD_PIECES)); + assertTrue(_check(1, ADD_PIECES, _machineBlob(DIGEST))); + assertTrue(_check(99, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_specificAndWildcardAreUnion() public { + _addMachine(7, _ops(TERMINATE)); + _addMachine(auth.WILDCARD_DATASET(), _ops(ADD_PIECES)); + assertTrue(_check(7, TERMINATE, _machineBlob(DIGEST))); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST)), "wildcard AddPieces still applies on ds 7"); + assertTrue(_check(8, ADD_PIECES, _machineBlob(DIGEST))); + assertFalse(_check(8, TERMINATE, _machineBlob(DIGEST))); + } + + function test_specificMatchPreferredInAuthorizedEvent() public { + bytes32 specific = _addMachine(7, _ops(ADD_PIECES)); + _addMachine(auth.WILDCARD_DATASET(), _ops(ADD_PIECES)); + + vm.expectEmit(true, true, false, true, address(auth)); + emit MultiMethodAuthorizer.Authorized(specific, ADD_PIECES, MultiMethodAuthorizer.Method.MachineP256); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_wrongOperationReturnsFalse() public { + _addMachine(7, _ops(ADD_PIECES)); + assertFalse(_check(7, TERMINATE, _machineBlob(DIGEST))); + assertFalse(_check(7, SCHEDULE_REMOVALS, _machineBlob(DIGEST))); + } + + function test_payerIsIgnored() public { + _addMachine(7, _ops(ADD_PIECES)); + bytes memory blob = _machineBlob(DIGEST); + assertTrue(auth.isAuthorized(7, address(0), ADD_PIECES, DIGEST, blob, "")); + assertTrue(auth.isAuthorized(7, stranger, ADD_PIECES, DIGEST, blob, "")); + } + + function test_machineAndPasskeyCredIdsAreDistinct() public { + _addMachine(7, _ops(ADD_PIECES)); + assertFalse(_check(7, ADD_PIECES, _goodPasskey(DIGEST, bytes32(0)))); + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _goodPasskey(DIGEST, bytes32(0)))); + } + + // ───────────────────────────── machine path ───────────────────────────── + + function test_machineHappyPath() public { + bytes32 credId = _addMachine(7, _ops(ADD_PIECES)); + vm.expectEmit(true, true, false, true, address(auth)); + emit MultiMethodAuthorizer.Authorized(credId, ADD_PIECES, MultiMethodAuthorizer.Method.MachineP256); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_machineBadSignatureReturnsFalse() public { + _addMachine(7, _ops(ADD_PIECES)); + (bytes32 r, bytes32 s) = _sign(DIGEST); + s = bytes32(uint256(s) ^ 1); + bytes memory blob = abi.encode(uint8(0), abi.encode(px, py, r, s)); + assertFalse(_check(7, ADD_PIECES, blob)); + } + + function test_machineHighSReturnsFalse() public { + _addMachine(7, _ops(ADD_PIECES)); + (bytes32 r, bytes32 s) = vm.signP256(PRIV, DIGEST); + s = _lowS(s); + bytes32 highS = bytes32(P256.N - uint256(s)); + bytes memory blob = abi.encode(uint8(0), abi.encode(px, py, r, highS)); + assertFalse(_check(7, ADD_PIECES, blob)); + } + + function test_machineUnregisteredKeyReturnsFalse() public { + (bytes32 r, bytes32 s) = _sign(DIGEST); + bytes memory blob = abi.encode(uint8(0), abi.encode(px, py, r, s)); + assertFalse(_check(7, ADD_PIECES, blob)); + } + + function test_disabledCredentialReturnsFalse() public { + bytes32 credId = _addMachine(7, _ops(ADD_PIECES)); + auth.setCredentialEnabled(credId, false); + assertFalse(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + auth.setCredentialEnabled(credId, true); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_expiryInclusiveThenRejects() public { + uint64 now_ = uint64(block.timestamp); + auth.addCredential(MultiMethodAuthorizer.Method.MachineP256, px, py, 7, bytes32(0), now_, _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST)), "expiry == block.timestamp is still valid"); + vm.warp(uint256(now_) + 1); + assertFalse(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_zeroExpiryNeverExpires() public { + _addMachine(7, _ops(ADD_PIECES)); + vm.warp(block.timestamp + 365 days); + assertTrue(_check(7, ADD_PIECES, _machineBlob(DIGEST))); + } + + function test_unknownMethodReverts() public { + _addMachine(7, _ops(ADD_PIECES)); + bytes memory blob = abi.encode(uint8(2), abi.encode(px, py, bytes32(0), bytes32(0))); + vm.expectRevert(abi.encodeWithSelector(MultiMethodAuthorizer.UnknownMethod.selector, uint8(2))); + _check(7, ADD_PIECES, blob); + } + + function test_malformedEnvelopeReverts() public { + vm.expectRevert(); + _check(7, ADD_PIECES, hex"deadbeef"); + } + + // ───────────────────────────── passkey path ───────────────────────────── + + function test_passkeyHappyPath() public { + bytes32 credId = _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + vm.expectEmit(true, true, false, true, address(auth)); + emit MultiMethodAuthorizer.Authorized(credId, ADD_PIECES, MultiMethodAuthorizer.Method.Passkey); + assertTrue(_check(7, ADD_PIECES, _goodPasskey(DIGEST, RP_ID))); + } + + function test_passkeyRequiresUserPresentAndVerified() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + string memory cd = _clientData(DIGEST, true); + + assertFalse(_check(7, ADD_PIECES, _passkeyBlob(_authData(RP_ID, 0x01), cd))); // UP only + assertFalse(_check(7, ADD_PIECES, _passkeyBlob(_authData(RP_ID, 0x04), cd))); // UV only + assertTrue(_check(7, ADD_PIECES, _passkeyBlob(_authData(RP_ID, 0x05), cd))); // both + assertTrue(_check(7, ADD_PIECES, _passkeyBlob(_authData(RP_ID, 0x07), cd))); // AT extra bit ok + } + + function test_passkeyAuthDataTooShortReturnsFalse() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + bytes memory ad = new bytes(36); + assertFalse(_check(7, ADD_PIECES, _passkeyBlob(ad, _clientData(DIGEST, true)))); + } + + function test_passkeyRpIdHashPin() public { + _addPasskey(7, RP_ID, _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _goodPasskey(DIGEST, RP_ID))); + assertFalse(_check(7, ADD_PIECES, _goodPasskey(DIGEST, keccak256("other.example")))); + } + + function test_passkeyZeroRpIdHashAcceptsAnyOrigin() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + assertTrue(_check(7, ADD_PIECES, _goodPasskey(DIGEST, keccak256("any.origin")))); + } + + function test_passkeySpacedJsonReturnsFalse() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + bytes memory ad = _authData(RP_ID, 0x05); + assertFalse(_check(7, ADD_PIECES, _passkeyBlob(ad, _clientData(DIGEST, false)))); + } + + function test_passkeyWrongChallengeReturnsFalse() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + bytes memory ad = _authData(RP_ID, 0x05); + string memory cd = _clientData(bytes32(uint256(0x2222)), true); + assertFalse(_checkDigest(7, ADD_PIECES, DIGEST, _passkeyBlob(ad, cd))); + } + + function test_passkeyWrongTypeReturnsFalse() public { + _addPasskey(7, bytes32(0), _ops(ADD_PIECES)); + bytes memory ad = _authData(RP_ID, 0x05); + string memory ch = auth.encodeChallenge(DIGEST); + string memory cd = string.concat('{"type":"webauthn.create","challenge":"', ch, '"}'); + assertFalse(_check(7, ADD_PIECES, _passkeyBlob(ad, cd))); + } + + function test_passkeyWildcardUsesWildcardRpIdHash() public { + _addPasskey(7, keccak256("specific.example"), _ops(TERMINATE)); + _addPasskey(auth.WILDCARD_DATASET(), RP_ID, _ops(ADD_PIECES)); + // AddPieces falls through to wildcard, so rpIdHash must match the wildcard pin. + assertTrue(_check(7, ADD_PIECES, _goodPasskey(DIGEST, RP_ID))); + assertFalse(_check(7, ADD_PIECES, _goodPasskey(DIGEST, keccak256("specific.example")))); + } +} From 760815a68aefcbaa859217669a26db805dc41627 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 13:17:21 +0100 Subject: [PATCH 4/8] Documentation updates --- service_contracts/README.md | 1 + .../src/examples/MultiMethodAuthorizer.md | 116 +++++++++--------- .../src/examples/MultiMethodAuthorizer.sol | 10 +- .../test/MultiMethodAuthorizer.t.sol | 14 ++- 4 files changed, 80 insertions(+), 61 deletions(-) diff --git a/service_contracts/README.md b/service_contracts/README.md index fbfa2d2e..d5f3f54e 100644 --- a/service_contracts/README.md +++ b/service_contracts/README.md @@ -8,6 +8,7 @@ This directory contains the smart contracts for different Filecoin services usin - `FilecoinWarmStorageService.sol` - A service contract with [PDP](https://github.com/FilOzone/pdp) (Proof of Data Possession) and payment integration - `FilecoinWarmStorageServiceStateView.sol` - View contract for reading `FilecoinWarmStorageService` with `eth_call`. - `IFilecoinServiceMetadata.sol` - Minimal service identity interface (`name`, `description`, `homepage`) + - `examples/MultiMethodAuthorizer.sol` - Reference P256 (machine key + passkey) authorizer for the optional per-data-set write ACL (PR #536); see `examples/MultiMethodAuthorizer.md` - `src/lib` - Library source files - `FilecoinWarmStorageServiceLayout.sol` - Constants conveying the storage layout of `FilecoinWarmStorageService` - `FilecoinWarmStorageServiceStateInternalLibrary.sol` - `internal` library for embedding logic to read `FilecoinWarmStorageService` diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md index 5f65359d..ee358461 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.md +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -1,8 +1,8 @@ -# MultiMethodAuthorizer — authorization envelope & credential registry (formal spec) +# MultiMethodAuthorizer — P256 direct sig and passkeys with fine-grained operation delegation in FWSS. -Formal description of the wire format ("UCAN"-style delegation envelope) and the on-chain -registry entries consumed by `MultiMethodAuthorizer`, an `IDataSetAuthorizer` for -filecoin-services PR #536. This document is normative for anyone building a client that signs +This document contains a formal description of the wire format and the on-chain registry +entries consumed by `MultiMethodAuthorizer`, an `IDataSetAuthorizer` for +filecoin-services. This document is normative for anyone building a client that signs for this authorizer or a contract that interoperates with it. Source of truth: [`MultiMethodAuthorizer.sol`](MultiMethodAuthorizer.sol). @@ -21,31 +21,26 @@ The authorizer splits a delegated authorization into two halves: enumerates exactly which operations it may authorize, plus an optional expiry and an enable/disable switch. -**Deployment model:** one clone (or standalone deploy) **per client**, not per data set. The +**Recommended deployment model:** one clone (or standalone deploy) **per client**. The client attaches that same address as authorizer on each FWSS data set they want it to govern, -then scopes grants in the registry. Wildcard credentials do **not** auto-attach the authorizer -to data sets — FWSS attachment is still per data set; wildcard only means "once attached, this -key may act." This is the cheap on-chain analogue of the session-key registry (one P256 grant -across all the client's data sets) while still allowing per-data-set grants on the same clone. +then grants per-dataset permissions in its registry, with a wildcard option to apply to all +datasets with this Authorizer attached. -### 1.1 Relationship to UCAN +### On-chain verification -This is a UCAN-style *delegation* — a key other than the payer is authorized to act — but the -capability and caveats are held **on-chain**, not inside a signed token. Mapping to UCAN terms: +This is a standard *delegation* — a key other than the payer is authorized to act — but the +capability and caveats are held **on-chain**, not inside a signed token. -| UCAN concept | Here | -|---|---| -| Issuer (`iss`) | The authorizer **owner** (the client) who calls `addCredential` | -| Audience (`aud`) | The registered **credential** (a P256 public key `(x, y)` scoped to a `dataSetId`) | -| Capability (`can` / resource) | An `(operation, dataSetId)` pair — `allowedOp[credId][operation]` plus the credential's `dataSetId` (or `WILDCARD_DATASET`) | -| Caveats | `expiry`, `enabled`, and (passkey) `rpIdHash` + user-verification requirement | -| Proof / invocation signature | The P256 signature over the FWSS operation `digest` (Section 6) | - -Consequence: unlike a token-carried UCAN, **there is no off-chain capability object to parse or -revoke** — delegation is granted and revoked by owner transactions (`addCredential` / -`removeCredential` / `setCredentialEnabled`), and the "challenge" being signed is the FWSS -operation digest itself. Replay/ordering is **not** in this envelope; it is FWSS's responsibility -(see [PLAYBOOK reviewer note](../../repos/filecoin_stuff/synapse-sdk/examples/authz/PLAYBOOK.md)). +Consequence: unlike a token-carried delegation such as UCAN, EIP-712 voucher, or JWT, **there is +no off-chain capability object to parse or revoke** — delegation is granted and revoked by owner +transactions (`addCredential` /`removeCredential` / `setCredentialEnabled`), and the "challenge" +being signed for Passkey operations is the FWSS operation digest itself. Replay/ordering is **not** +in this envelope; it is FWSS's responsibility (AddPieces: per-payer nonce; Terminate: terminal-state +guard). + +Since all this happens in SP-proxied calls there is nothing special required for Curio (or +equivalent node software) to do: if FWSS's `isAuthorized` fails then the operation reverts +and the call errors out. --- @@ -72,7 +67,7 @@ operation digest itself. Replay/ordering is **not** in this envelope; it is FWSS ```solidity enum Method { MachineP256 /*0*/, Passkey /*1*/ } -uint256 constant WILDCARD_DATASET = type(uint256).max; // all attached data sets +uint256 constant WILDCARD_DATASET = type(uint256).max; // should never clash struct Credential { Method method; // 0 = machine key, 1 = WebAuthn passkey @@ -81,7 +76,7 @@ struct Credential { uint256 dataSetId; // specific FWSS data set, or WILDCARD_DATASET bytes32 rpIdHash; // Passkey only: expected SHA-256(rpId); 0 = accept any origin uint64 expiry; // unix seconds; 0 = no expiry - bool enabled; // owner kill-switch + bool enabled; // temporary revocation bytes32[] ops; // live grant list (source of truth for remove / replace) } @@ -97,7 +92,8 @@ credId = keccak(abi.encode(Method method, uint256 x, uint256 y, uint256 dataSetI The same P256 key may therefore be registered more than once — e.g. AddPieces-only on data set 7, and a separate wildcard credential for Terminate on every attached data set. Those are two -`credId`s. +`credId`s. If taking advantage of this capability ensure you delete all credentials for a key +if you wish to revoke it. **Operation identifiers** — the FWSS EIP-712 struct type-hashes (the `operation` argument of `isAuthorized` and the key of `allowedOp`): @@ -108,28 +104,42 @@ The same P256 key may therefore be registered more than once — e.g. AddPieces- | SchedulePieceRemovals | `0x5415701e313bb627e755b16924727217bb356574fe20e7061442c200b0822b22` | | TerminateService | `0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc` | -**Owner API** (only the authorizer's `owner`; see PLAYBOOK for cast invocations): +**Owner API** (only the authorizer's `owner` can call): `addCredential(method, x, y, dataSetId, rpIdHash, expiry, ops[])` — registers, or **replaces** the grant set if the same `(method, key, dataSetId)` already exists; -`setOperationAllowed(credId, operation, allowed)` — the only incremental permission edit; -`setCredentialEnabled`, `setCredentialExpiry`, `removeCredential(credId)` — wipes the credential -*and* every granted op (callers do not list ops); `transferOwnership`. +`setOperationAllowed(credId, operation, allowed)` — edit permissions on an existing credential; +`setCredentialExpiry`, `setCredentialEnabled`, `removeCredential(credId)` — increasingly powerful revocation operations; +`transferOwnership` (rejects `address(0)`). + +**Usage** — deploy once per client, attach per data set, then grant keys: + +``` +auth = new MultiMethodAuthorizer() # owner = msg.sender (constructor) +fwss.setDataSetAuthorizer(dataSetId, address(auth)) # must be called by dataset payer + +# machine key, AddPieces on one data set only: +auth.addCredential(0, x, y, dataSetId, 0, 0, [ADD_PIECES_TYPEHASH]) + +# or grant that key addPieces on every data set this authorizer is attached to: +auth.addCredential(0, x, y, type(uint256).max, 0, 0, [ADD_PIECES_TYPEHASH]) + +# client envelope for a machine signature over the FWSS digest: +signature = abi.encode(uint8(0), abi.encode(x, y, r, s)) +``` A credential **authorizes** `(operation, dataSetId)` iff: `enabled ∧ (expiry == 0 ∨ block.timestamp ≤ expiry) ∧ allowedOp[credId][operation]` **and** the credential's `dataSetId` is either the requested data set or `WILDCARD_DATASET`. -Lookup in `isAuthorized` prefers the **specific** credential `(method, x, y, dataSetId)` and, if -that does not currently authorize the operation, falls back to the **wildcard** credential -`(method, x, y, WILDCARD_DATASET)`. The two grants are a union: a wildcard AddPieces still -authorizes AddPieces on data set 7 even if a specific credential for data set 7 exists but -does not list AddPieces. To deny a key on one data set while keeping a wildcard, remove the -wildcard and register per-data-set credentials instead. +Lookup in `isAuthorized` prefers a **specific** credential for the dataSet/key pair +`(method, x, y, dataSetId)` if available, then falls back to **wildcard** credentials +for that key `(method, x, y, WILDCARD_DATASET)`. ### 3.1 `expiry` and time on FEVM -`expiry` is compared against `block.timestamp`. The FEVM does **not** read wall-clock time — it -synthesizes `block.timestamp` deterministically from the tipset height: +For ease of use and clarity of intention, expiries are expressed as real-world times and dates, not +block numbers or epochs or the like. When chcking expiry, `expiry` is compared against +`block.timestamp`, synthesized deterministically from the tipset height: ``` block.timestamp = genesis_unix + epoch × blocktime # blocktime = 30 s mainnet, 4 s on the FOC devnet @@ -143,10 +153,7 @@ Consequences an implementer must respect: is safe to gate on — a stronger guarantee than Ethereum's proposer-influenced timestamp. - **Anchor `expiry` to chain time, not the local clock.** Chain time only advances when blocks are produced, so on a devnet (or any idle chain) it can diverge from real wall-clock by hours. Clients - MUST set `expiry = + duration_seconds`, never `Date.now()/1000 + duration`. -- Epoch-native alternative: a variant could gate on `block.number` (which on FEVM is the Filecoin - epoch) and express `expiry` in epochs, removing the genesis/blocktime conversion. This contract - uses `block.timestamp` for EVM-tooling legibility; the semantics above are identical either way. + SHOULD set `expiry = + duration_seconds`, not `Date.now()/1000 + duration`. --- @@ -255,11 +262,13 @@ subclasses that want content-level ACLs (e.g. metadata/path gating). ### 6.1 What the digest binds (and what it doesn't) The `digest` is FWSS's EIP-712 operation digest. It cryptographically binds the operation to its -parameters (for AddPieces: `clientDataSetId, nonce, pieceData[], metadata` — including a per-payer -nonce). The passkey path additionally binds `digest` into the WebAuthn challenge, so a passkey -assertion cannot be re-pointed at a different operation. This authorizer keeps **no nonce of its -own**; replay protection for each operation is FWSS's (AddPieces: client nonce; Terminate: -terminal-state guard; SchedulePieceRemovals: delegated upstream — flagged for reviewers). +parameters (for AddPieces: `clientDataSetId, nonce, pieceData[], metadata`). The passkey path +additionally binds `digest` into the WebAuthn challenge, so a passkey assertion cannot be +re-pointed at a different operation. + +This authorizer keeps **no nonce of its own**; replay protection for each operation is FWSS's +(AddPieces: client nonce; Terminate: terminal-state guard; SchedulePieceRemovals: delegated +upstream). --- @@ -294,7 +303,7 @@ addCredential(0 /*MachineP256*/, x, y, type(uint256).max /*WILDCARD_DATASET*/, 0 - **Always low-`s` normalize** before sending; the precompile rejects high-`s`, which reads as an auth failure. -- **UV is load-bearing** for the passkey method: it is the on-chain evidence that a human verified +- **UV is required** for the passkey method: it is the on-chain evidence that a human verified (Touch ID / secure enclave). A client that requests a non-verifying assertion (`userVerification` ≠ `required`) will be rejected (`flags & 0x04 == 0`). - **rpIdHash pinning:** set a non-zero `rpIdHash` on passkey credentials to bind them to a specific @@ -302,11 +311,8 @@ addCredential(0 /*MachineP256*/, x, y, type(uint256).max /*WILDCARD_DATASET*/, 0 - **Revocation is on-chain and immediate:** `setCredentialEnabled(credId, false)` disables; `removeCredential(credId)` deletes the entry *and* every `allowedOp` slot on its grant list. Re-adding the same key starts from a clean grant set. `addCredential` replace is also a full - replace — it does not union with leftover ops. -- **Wildcard is a union, not a default-deny overlay.** A wildcard credential still authorizes on - a data set that also has a more specific credential for the same key. There is no per-data-set - exception list — restrict a key by dropping the wildcard and issuing specific grants. + replace — it does not union with leftover ops. `transferOwnership` rejects `address(0)`. - **Attachment is still per data set.** `WILDCARD_DATASET` does not make the authorizer apply to data sets the payer has not attached it to. -- The signature envelope proves authentication only — never treat a valid signature as +- **The signature envelope proves authentication only:** never treat a valid signature as authorization without the registry check. diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index 68c8a256..6fb8a3b8 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.21; // Reference authorizer for the optional per-data-set write ACL (PR #536). One clone (or standalone @@ -7,8 +7,10 @@ pragma solidity ^0.8.21; // profile (solc 0.8.30, via_ir, optimizer_runs=200, bytecode_hash="none") so it has a stable code // identity for SP allowlisting. See MultiMethodAuthorizer.md for the wire-format spec. // -// The IDataSetAuthorizer interface is inlined so this compiles before #536 merges. Once #536 lands, -// replace this inline copy with an import of the canonical `src/interfaces/IDataSetAuthorizer.sol`. +// IDataSetAuthorizer is inlined (not imported) so this example compiles on its own, independent of +// whether PR #536 has merged. Merge order is up to maintainers: once +// `src/interfaces/IDataSetAuthorizer.sol` exists on the target branch, replace this copy with that +// import. Keep the function signature identical to #536's interface. /// filecoin-services PR #536 IDataSetAuthorizer (state-mutating CALL). interface IDataSetAuthorizer { @@ -86,6 +88,7 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { error NotOwner(); error UnknownMethod(uint8 method); error UnknownCredential(bytes32 credId); + error ZeroOwner(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); @@ -179,6 +182,7 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { } function transferOwnership(address to) external onlyOwner { + if (to == address(0)) revert ZeroOwner(); emit OwnershipTransferred(owner, to); owner = to; } diff --git a/service_contracts/test/MultiMethodAuthorizer.t.sol b/service_contracts/test/MultiMethodAuthorizer.t.sol index e68993c7..8d9abbc7 100644 --- a/service_contracts/test/MultiMethodAuthorizer.t.sol +++ b/service_contracts/test/MultiMethodAuthorizer.t.sol @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.30; import {Test} from "forge-std/Test.sol"; @@ -204,11 +204,19 @@ contract MultiMethodAuthorizerTest is Test { clone.initialize(address(0)); } - function test_transferOwnershipToZeroLocksRegistry() public { + function test_transferOwnershipRejectsZero() public { + vm.expectRevert(MultiMethodAuthorizer.ZeroOwner.selector); auth.transferOwnership(address(0)); - assertEq(auth.owner(), address(0)); + assertEq(auth.owner(), address(this)); + } + + function test_transferOwnershipMovesAdmin() public { + auth.transferOwnership(stranger); + assertEq(auth.owner(), stranger); vm.expectRevert(MultiMethodAuthorizer.NotOwner.selector); _addMachine(1, _ops(ADD_PIECES)); + vm.prank(stranger); + _addMachine(1, _ops(ADD_PIECES)); } function test_addCredentialOverwriteReplacesOps() public { From 5439f75b7f1ce5cf0ff50d9a5e6b2d688de8f1f3 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 14:27:11 +0100 Subject: [PATCH 5/8] Reproducible builds docs --- .../src/examples/MultiMethodAuthorizer.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md index ee358461..603c657c 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.md +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -316,3 +316,76 @@ addCredential(0 /*MachineP256*/, x, y, type(uint256).max /*WILDCARD_DATASET*/, 0 data sets the payer has not attached it to. - **The signature envelope proves authentication only:** never treat a valid signature as authorization without the registry check. + +## 9. Reproducible build & code identity + +Storage providers' authorizer allowlists match by the `keccak256` of contract **runtime bytecode**, +so standard/blessed contracts require reproducible builds in order to match a default configuration. + +Codehash is **not** affected by deployment: the contract has no immutables and no constructor +arguments that touch runtime code, so anyone can deploy it — any network, any account — and get +byte-identical runtime code. **Reproduce the build exactly as below, then you can deploy and manage your own instance for your datasets.** + +### Pinned build inputs + +NOTE: and will be updated in a fast-follow PR AFTER this is merged to main. + +| Input | Value | +|---|---| +| Source | `src/examples/MultiMethodAuthorizer.sol` @ commit `` | +| solc | **0.8.30** (exact) | +| `via_ir` | **true** | +| optimizer | **enabled**, `runs = 200` | +| `evm_version` | **prague** — the solc-0.8.30 default and the version FWSS compiles to (FEVM-compatible). Pinning is mandatory; an unpinned / `osaka` / `cancun` build yields a different hash. | +| `bytecode_hash` | **none** | +| `cbor_metadata` | **false** — removes the trailing CBOR metadata blob so the raw runtime hash equals what an SP computes (no tail to strip). | + +### Build (self-contained; independent of the repo's default `foundry.toml`) + +```bash +cd service_contracts +git checkout # pin the exact source + +FOUNDRY_BYTECODE_HASH=none \ +FOUNDRY_CBOR_METADATA=false \ +FOUNDRY_EVM_VERSION=prague \ +FOUNDRY_OPTIMIZER=true \ +FOUNDRY_OPTIMIZER_RUNS=200 \ +FOUNDRY_VIA_IR=true \ +forge build --use 0.8.30 --skip '*.s.sol' --skip 'test/**' --force +``` + +### Derive the code identity + +```bash +cast keccak 0x$(jq -r '.deployedBytecode.object' \ + out/MultiMethodAuthorizer.sol/MultiMethodAuthorizer.json | sed 's/^0x//') +``` + +Canonical runtime codehash for this source + these inputs: + +``` +> +``` + +Sanity checks on the artifact: `deployedBytecode.immutableReferences` must be `{}` (no immutables), +and the bytecode must **not** end in the solc CBOR marker `…0033` (no metadata tail). + +### Verify a deployed instance + +```bash +cast code --rpc-url | xargs -I{} cast keccak {} +# must equal the canonical codehash above +``` + +### Registry entry + +```toml +[[Subsystems.PDPAuthorizers.ApprovedAuthorizers]] + Label = "MultiMethodAuthorizer v1 (standalone)" + Kind = "codehash" + CodeHash = "0x433290b66652670f1930fe8e12b8a64f648a580355d08ca39fce44f69b43e955" + Notes = "solc 0.8.30, via_ir, runs=200, evm_version=prague, no metadata; source @ ; audit: " +``` + +Toolchain used to produce the hash above: `forge`/`cast` 1.5.1, solc 0.8.30, based on code at commit `` From 1f94c6c661eb1116519e7b1cd6f4ad6ab63d75b5 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 14:35:42 +0100 Subject: [PATCH 6/8] Format fixes --- .../src/examples/MultiMethodAuthorizer.sol | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index 6fb8a3b8..bcb095e3 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -50,7 +50,10 @@ interface IDataSetAuthorizer { /// Replay is handled by FWSS itself (the digest is operation-unique and FWSS enforces its own /// nonces / termination state), so this authorizer stays a pure authenticate-and-authorize gate. contract MultiMethodAuthorizer is IDataSetAuthorizer { - enum Method { MachineP256, Passkey } + enum Method { + MachineP256, + Passkey + } struct Credential { Method method; @@ -230,7 +233,10 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { } /// method 0 — machine key signs the FWSS digest directly. - function _machine(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + function _machine(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) + internal + returns (bool) + { (uint256 x, uint256 y, bytes32 r, bytes32 s) = abi.decode(payload, (uint256, uint256, bytes32, bytes32)); (bytes32 credId, bool allowed) = _lookupCred(Method.MachineP256, x, y, dataSetId, operation); if (!allowed) return false; @@ -241,7 +247,10 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { /// method 1 — WebAuthn passkey: verify presence+verification and that the assertion's /// challenge is exactly the FWSS digest, then P256-verify the WebAuthn message. - function _passkey(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) internal returns (bool) { + function _passkey(uint256 dataSetId, bytes32 operation, bytes32 digest, bytes memory payload) + internal + returns (bool) + { (uint256 x, uint256 y, bytes memory authData, string memory clientDataJSON, bytes32 r, bytes32 s) = abi.decode(payload, (uint256, uint256, bytes, string, bytes32, bytes32)); (bytes32 credId, bool allowed) = _lookupCred(Method.Passkey, x, y, dataSetId, operation); From a903964797296d9026a34a21f3acee7bdb29794c Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 14:42:20 +0100 Subject: [PATCH 7/8] More linter fixups --- service_contracts/src/examples/MultiMethodAuthorizer.md | 4 ++-- service_contracts/src/examples/MultiMethodAuthorizer.sol | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.md b/service_contracts/src/examples/MultiMethodAuthorizer.md index 603c657c..3ea76fcd 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.md +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -365,7 +365,7 @@ cast keccak 0x$(jq -r '.deployedBytecode.object' \ Canonical runtime codehash for this source + these inputs: ``` -> + ``` Sanity checks on the artifact: `deployedBytecode.immutableReferences` must be `{}` (no immutables), @@ -384,7 +384,7 @@ cast code --rpc-url | xargs -I{} cast keccak {} [[Subsystems.PDPAuthorizers.ApprovedAuthorizers]] Label = "MultiMethodAuthorizer v1 (standalone)" Kind = "codehash" - CodeHash = "0x433290b66652670f1930fe8e12b8a64f648a580355d08ca39fce44f69b43e955" + CodeHash = "" Notes = "solc 0.8.30, via_ir, runs=200, evm_version=prague, no metadata; source @ ; audit: " ``` diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index bcb095e3..67b81ee8 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -94,10 +94,14 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { error ZeroOwner(); modifier onlyOwner() { - if (msg.sender != owner) revert NotOwner(); + _onlyOwner(); _; } + function _onlyOwner() internal view { + if (msg.sender != owner) revert NotOwner(); + } + /// Standalone deploys set the owner here. Constructor logic lives in creation code, not runtime /// code, so it does not affect the runtime bytecode / code identity used for SP allowlisting. constructor() { From a164ad9c44929d6c80926819d1480a6891baa5ef Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Tue, 18 Aug 2026 15:16:36 +0100 Subject: [PATCH 8/8] Fix EVM versioning headaches with P256 precompile --- service_contracts/Makefile | 8 +++++-- .../src/examples/MultiMethodAuthorizer.sol | 8 +++++++ .../test/MultiMethodAuthorizer.t.sol | 24 ------------------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/service_contracts/Makefile b/service_contracts/Makefile index 6f72c1f0..45af362b 100644 --- a/service_contracts/Makefile +++ b/service_contracts/Makefile @@ -82,10 +82,14 @@ check-tools: fi @which forge >/dev/null 2>&1 || (echo "Error: forge is required but not installed" && exit 1) -# Test target +# Test target. +# MultiMethodAuthorizer.t.sol exercises the secp256r1 precompile at 0x100, which revm only implements +# at evm_version=osaka (RIP-7212 / EIP-7951). The default evm_version reserves 0x100 but returns empty, +# so that suite is run separately at osaka; every other test stays on the repo default. .PHONY: test test: - forge test --via-ir -vv + forge test --via-ir -vv --no-match-contract MultiMethodAuthorizerTest + FOUNDRY_EVM_VERSION=osaka forge test --via-ir -vv --match-contract MultiMethodAuthorizerTest # Clean build artifacts .PHONY: clean diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol index 67b81ee8..95042cfc 100644 --- a/service_contracts/src/examples/MultiMethodAuthorizer.sol +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -76,6 +76,11 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { /// contract. A precompile-less-chain fallback would be a separate, separately-audited contract, /// not a per-deployer knob. address public constant P256_VERIFIER = address(0x100); + + /// secp256r1 (P256) curve order n. High-S signatures (s > n/2) are malleable, and the 0x100 + /// precompile does NOT reject them — so this authorizer enforces low-S itself (see _verifyP256). + uint256 internal constant _P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551; + address public owner; mapping(bytes32 credId => Credential) public credentials; @@ -327,6 +332,9 @@ contract MultiMethodAuthorizer is IDataSetAuthorizer { } function _verifyP256(bytes32 hash, bytes32 r, bytes32 s, uint256 x, uint256 y) internal view returns (bool) { + // Reject high-S (malleable) signatures: the 0x100 precompile accepts them, so we enforce + // low-S here to match the spec and prevent signature malleability. + if (uint256(s) > _P256_N / 2) return false; bytes memory input = abi.encodePacked(hash, r, s, bytes32(x), bytes32(y)); (bool ok, bytes memory out) = P256_VERIFIER.staticcall(input); return ok && out.length == 32 && bytes32(out) == bytes32(uint256(1)); diff --git a/service_contracts/test/MultiMethodAuthorizer.t.sol b/service_contracts/test/MultiMethodAuthorizer.t.sol index 8d9abbc7..37ae0f0e 100644 --- a/service_contracts/test/MultiMethodAuthorizer.t.sol +++ b/service_contracts/test/MultiMethodAuthorizer.t.sol @@ -6,29 +6,6 @@ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {P256} from "@openzeppelin/contracts/utils/cryptography/P256.sol"; import {MultiMethodAuthorizer} from "../src/examples/MultiMethodAuthorizer.sol"; -/// RIP-7212-shaped shim so tests do not depend on a native 0x100 precompile. -contract Rip7212Shim { - fallback() external { - bytes32 hash; - bytes32 r; - bytes32 s; - bytes32 x; - bytes32 y; - assembly { - hash := calldataload(0) - r := calldataload(32) - s := calldataload(64) - x := calldataload(96) - y := calldataload(128) - } - bool ok = P256.verifySolidity(hash, r, s, x, y); - assembly { - mstore(0x00, ok) - return(0x00, 0x20) - } - } -} - contract MultiMethodAuthorizerTest is Test { using Clones for address; @@ -47,7 +24,6 @@ contract MultiMethodAuthorizerTest is Test { address internal stranger; function setUp() public { - vm.etch(address(0x100), type(Rip7212Shim).runtimeCode); auth = new MultiMethodAuthorizer(); (px, py) = vm.publicKeyP256(PRIV); stranger = makeAddr("stranger");