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/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 new file mode 100644 index 00000000..3ea76fcd --- /dev/null +++ b/service_contracts/src/examples/MultiMethodAuthorizer.md @@ -0,0 +1,391 @@ +# MultiMethodAuthorizer — P256 direct sig and passkeys with fine-grained operation delegation in FWSS. + +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). + +--- + +## 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, 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. + +**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 grants per-dataset permissions in its registry, with a wildcard option to apply to all +datasets with this Authorizer attached. + +### On-chain verification + +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. + +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. + +--- + +## 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*/ } + +uint256 constant WILDCARD_DATASET = type(uint256).max; // should never clash + +struct Credential { + 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; // temporary revocation + bytes32[] ops; // live grant list (source of truth for remove / replace) +} + +mapping(bytes32 credId => Credential) credentials; +mapping(bytes32 credId => mapping(bytes32 operation => bool)) allowedOp; +``` + +**Credential identifier** (deterministic, collision-resistant per method+key+data set): + +``` +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. 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`): + +| Operation | `operation` typehash | +|---|---| +| AddPieces | `0x954bdc254591a7eab1b73f03842464d9283a08352772737094d710a4428fd183` | +| SchedulePieceRemovals | `0x5415701e313bb627e755b16924727217bb356574fe20e7061442c200b0822b22` | +| TerminateService | `0x522bd88a11de1cdc6574394dde7a21ae488ff13e16e7408d0ea721dd8479dffc` | + +**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)` — 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 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 + +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 +``` + +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 + SHOULD set `expiry = + duration_seconds`, not `Date.now()/1000 + duration`. + +--- + +## 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 `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**. + +### 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, 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` | + +**`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. 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`. +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`. + - 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`). 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). + +--- + +## 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, 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]) +``` + +--- + +## 8. Security notes for implementers + +- **Always low-`s` normalize** before sending; the precompile rejects high-`s`, which reads as an + auth failure. +- **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 + 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)` 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. `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 + 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 = "" + 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 `` diff --git a/service_contracts/src/examples/MultiMethodAuthorizer.sol b/service_contracts/src/examples/MultiMethodAuthorizer.sol new file mode 100644 index 00000000..95042cfc --- /dev/null +++ b/service_contracts/src/examples/MultiMethodAuthorizer.sol @@ -0,0 +1,393 @@ +// 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 +// 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. +// +// 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 { + 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, 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) +/// 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; + 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; + bytes32[] ops; // live grant list; addCredential replaces it, removeCredential wipes it + } + + /// 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 + /// 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; + 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, 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); + event CredentialRemoved(bytes32 indexed credId); + + error NotOwner(); + error UnknownMethod(uint8 method); + error UnknownCredential(bytes32 credId); + error ZeroOwner(); + + modifier onlyOwner() { + _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() { + 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). 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 { + require(owner == address(0), "already initialized"); + require(initialOwner != address(0), "zero owner"); + owner = initialOwner; + emit OwnershipTransferred(address(0), initialOwner); + } + + // ───────────────────────── owner: registry management ───────────────────────── + + /// 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( + 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, dataSetId); + _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++) { + _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 { + if (!_exists(credId)) revert UnknownCredential(credId); + if (allowed) _grantOp(credId, operation); + else _revokeOp(credId, operation); + 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, 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]; + 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 { + if (to == address(0)) revert ZeroOwner(); + emit OwnershipTransferred(owner, to); + owner = to; + } + + 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. + function encodeChallenge(bytes32 challenge) external pure returns (string memory) { + return _b64url(abi.encodePacked(challenge)); + } + + // ───────────────────────────── authorization ───────────────────────────── + + /// @inheritdoc IDataSetAuthorizer + 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(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(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; + 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(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); + if (!allowed) return false; + Credential storage c = credentials[credId]; + + // 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 _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; + 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) { + // 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)); + } + + // ───────────────────────────── 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; + } +} diff --git a/service_contracts/test/MultiMethodAuthorizer.t.sol b/service_contracts/test/MultiMethodAuthorizer.t.sol new file mode 100644 index 00000000..37ae0f0e --- /dev/null +++ b/service_contracts/test/MultiMethodAuthorizer.t.sol @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: Apache-2.0 OR 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"; + +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 { + 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_transferOwnershipRejectsZero() public { + vm.expectRevert(MultiMethodAuthorizer.ZeroOwner.selector); + auth.transferOwnership(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 { + 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")))); + } +}