Skip to content

Commit 1211837

Browse files
Merge fix/audit-2026-07 into main (FIXLIST 2026-07-01 remediation)
2 parents f7f018c + f5daca5 commit 1211837

31 files changed

Lines changed: 1327 additions & 49 deletions

.gitmodules

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[submodule "contracts/lib/forge-std"]
2+
path = contracts/lib/forge-std
3+
url = https://github.com/foundry-rs/forge-std
4+
[submodule "contracts/lib/openzeppelin-contracts"]
5+
path = contracts/lib/openzeppelin-contracts
6+
url = https://github.com/OpenZeppelin/openzeppelin-contracts

CREDENTIALS_NEEDED.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ Set under `services.<name>.*` in `openmatrix.config.json`. Each service returns
9898
|---|---|---|
9999
| Deploy key / fine-grained PAT with read on `Morpheus-Security-System` | deploy CI / image build | `pip install git+ssh://…/Morpheus-Security-System` so the seam binds real enforcement. Absent → platform runs with `SECURITY_BACKEND=noop` (safe, inert). See `Morpheus-Security-System/DEPLOY_ASSEMBLY.md`. |
100100

101+
## 8. Sign in with Apple — server credentials (P1-8)
102+
103+
| Credential | Where | Unlocks |
104+
|---|---|---|
105+
| `auth.apple.bundle_id` (`com.opnmatrx.mtrx`) | `openmatrix.config.json` | **Required** for `POST /api/v1/auth/apple` — the identity-token audience check. Unset → route fails closed (503). |
106+
| `auth.apple.team_id` + `key_id` + `private_key_p8` (Sign in with Apple key) | `openmatrix.config.json` / secret | Token **revocation** on account deletion (`DELETE /api/v1/auth/account`). App Review requires working deletion once server accounts are live. Unconfigured → local data still deleted, Apple revocation skipped with a WARNING. |
107+
101108
## 7. Per-component contract addresses (app) — fill AFTER `deploy_all.py`
102109

103110
`scripts/deploy_all.py` deploys the platform contracts and writes

bridge/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,13 @@
3939
# Ethereum-mainnet's EAS (0xA1207...0eb1582Ce587) — it was wrong.
4040
EAS_CONTRACT = "0x4200000000000000000000000000000000000021"
4141

42-
# Schema UID for bridge attestations
43-
EAS_SCHEMA_UID = 348
42+
# Schema UID for bridge attestations — EMPTY by default (P2-9). An EAS schema
43+
# UID is a chain-specific keccak256 bytes32, NOT an easscan display number like
44+
# "348"; a fabricated default attests against a nonexistent schema. Supply the
45+
# real registered UID via config["blockchain"]["schemas"]["primary"] and resolve
46+
# it through runtime.blockchain.services.attestation.schemas.get_schema_uid,
47+
# which fails closed on an empty/malformed value.
48+
EAS_SCHEMA_UID: str = ""
4449

4550
from bridge.exporter import ComponentExporter
4651
from bridge.sanitizer import SanitizationValidator

contracts/DEPLOYMENT_GUIDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ into `openmatrix.config.json`.
1111
- A funded deployer wallet
1212
- Sepolia ETH faucet: <https://www.alchemy.com/faucets/base-sepolia>
1313
- `solc` 0.8.20 (auto-installed by `py-solc-x` on first run)
14+
- Solidity dependencies (forge-std + OpenZeppelin v5.0.2) live under `contracts/lib/`
15+
as git submodules. On a fresh checkout, fetch them before `forge build`:
16+
17+
```bash
18+
git submodule update --init --recursive # forge-std + openzeppelin-contracts
19+
# or, without submodules:
20+
# forge install foundry-rs/forge-std OpenZeppelin/openzeppelin-contracts@v5.0.2 --no-commit
21+
```
1422

1523
## 1. Configure environment
1624

contracts/OpenMatrixDAO.sol

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ contract OpenMatrixDAO is ReentrancyGuard, Ownable {
137137
p.endBlock = block.number + VOTING_DELAY + VOTING_PERIOD;
138138
p.targets = targets;
139139
p.values = values;
140-
p.calldatas = calldatas;
140+
// Element-wise copy: the legacy code generator (via_ir=false, foundry.toml)
141+
// cannot copy a nested calldata dynamic array (bytes[]) straight into
142+
// storage — it raises UnimplementedFeatureError. targets/values are
143+
// value-type arrays and copy fine; only calldatas needs the loop (P0-3).
144+
for (uint256 i = 0; i < calldatas.length; i++) {
145+
p.calldatas.push(calldatas[i]);
146+
}
141147

142148
emit ProposalCreated(proposalId, msg.sender, votingModel, p.startBlock, p.endBlock, description);
143149
}

contracts/lib/forge-std

Submodule forge-std added at 3b20d60
Submodule openzeppelin-contracts added at dbb6104

contracts/test/OpenMatrixPaymaster.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import "../OpenMatrixPaymaster.sol";
99
contract OpenMatrixPaymasterTest is Test {
1010
OpenMatrixPaymaster internal paymaster;
1111

12-
address internal constant PLATFORM = address(0xP1A7);
12+
address internal constant PLATFORM = address(0x1A7) /* was invalid literal 0xP1A7 — "P" is not hex (P0-3 adjacent fix) */;
1313
address internal owner;
1414
address internal agent;
1515
address internal stranger;

gateway/apple_auth.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""P1-8: Sign in with Apple — server-side identity-token verification and
2+
(credential-gated) token revocation for account deletion.
3+
4+
The iOS app sends Apple's ``identityToken`` (a JWT signed by Apple). We verify
5+
it against Apple's published JWKS: RS256 signature, ``iss`` == Apple, ``aud`` ==
6+
our bundle id, and ``exp``. Revocation on account deletion needs a client-secret
7+
JWT signed with the team's ``.p8`` — only performed when those credentials are
8+
configured; otherwise local data is still deleted and revocation is skipped with
9+
a WARNING.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import time
16+
from typing import Any
17+
18+
logger = logging.getLogger(__name__)
19+
20+
APPLE_ISSUER = "https://appleid.apple.com"
21+
APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"
22+
APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"
23+
_JWKS_TTL_SECONDS = 3600
24+
25+
26+
class AppleAuthError(Exception):
27+
"""Identity-token verification failed (generic — no internal detail leaks)."""
28+
29+
30+
class AppleAuthNotConfigured(Exception):
31+
"""auth.apple.bundle_id is unset — the route must fail closed (503)."""
32+
33+
34+
class AppleJWKSCache:
35+
"""Fetches and TTL-caches Apple's JWKS. ``fetcher`` is injectable for tests."""
36+
37+
def __init__(self, fetcher=None, ttl: float = _JWKS_TTL_SECONDS) -> None:
38+
self._fetcher = fetcher or _default_fetch_jwks
39+
self._ttl = ttl
40+
self._keys: dict[str, Any] = {}
41+
self._fetched_at: float = 0.0
42+
43+
async def get_key(self, kid: str, *, now: float):
44+
if not self._keys or (now - self._fetched_at) > self._ttl:
45+
raw = await self._fetcher()
46+
self._keys = {k["kid"]: k for k in raw.get("keys", [])}
47+
self._fetched_at = now
48+
return self._keys.get(kid)
49+
50+
51+
async def _default_fetch_jwks() -> dict:
52+
import aiohttp
53+
54+
async with aiohttp.ClientSession() as session:
55+
async with session.get(APPLE_JWKS_URL, timeout=aiohttp.ClientTimeout(total=10)) as r:
56+
return await r.json()
57+
58+
59+
def apple_bundle_id(config: dict) -> str:
60+
auth = ((config or {}).get("auth") or {}).get("apple") or {}
61+
return str(auth.get("bundle_id", "")).strip()
62+
63+
64+
async def verify_apple_identity_token(
65+
identity_token: str,
66+
*,
67+
config: dict,
68+
jwks_cache: AppleJWKSCache,
69+
now: float | None = None,
70+
) -> dict:
71+
"""Verify Apple's identity token; return its claims or raise.
72+
73+
Fail-closed: if bundle_id is unconfigured we NEVER verify against a wildcard
74+
audience — the caller returns 503.
75+
"""
76+
bundle_id = apple_bundle_id(config)
77+
if not bundle_id:
78+
raise AppleAuthNotConfigured()
79+
80+
try:
81+
import jwt
82+
from jwt.algorithms import RSAAlgorithm
83+
except Exception as exc: # PyJWT / cryptography missing
84+
raise AppleAuthError("jwt library unavailable") from exc
85+
86+
now = time.time() if now is None else now
87+
try:
88+
header = jwt.get_unverified_header(identity_token)
89+
except Exception as exc:
90+
raise AppleAuthError("malformed token") from exc
91+
92+
kid = header.get("kid")
93+
jwk = await jwks_cache.get_key(kid, now=now)
94+
if jwk is None:
95+
raise AppleAuthError("unknown signing key")
96+
97+
try:
98+
public_key = RSAAlgorithm.from_jwk(jwk)
99+
claims = jwt.decode(
100+
identity_token,
101+
public_key,
102+
algorithms=["RS256"],
103+
audience=bundle_id,
104+
issuer=APPLE_ISSUER,
105+
options={"require": ["exp", "iss", "aud"]},
106+
)
107+
except Exception as exc:
108+
raise AppleAuthError("token verification failed") from exc
109+
return claims
110+
111+
112+
def apple_revocation_configured(config: dict) -> bool:
113+
auth = ((config or {}).get("auth") or {}).get("apple") or {}
114+
return bool(auth.get("team_id") and auth.get("key_id") and auth.get("private_key_p8"))

gateway/bridge.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,9 @@ def register_routes(self, app: web.Application) -> None:
543543
app.router.add_post("/bridge/v1/wallet/link", self.link_wallet)
544544
app.router.add_get("/bridge/v1/wallet/status", self.wallet_status)
545545

546+
# Push notification registration (P1-6)
547+
app.router.add_post("/bridge/v1/push/register", self.register_push)
548+
546549
# App config
547550
app.router.add_get("/bridge/v1/config", self.get_config)
548551
app.router.add_get("/bridge/v1/services", self.get_services)
@@ -738,8 +741,12 @@ async def execute_action(self, request: web.Request) -> web.Response:
738741
return MobileResponse.error(generic_denial(decision), 403)
739742

740743
try:
741-
from runtime.blockchain.services.service_dispatcher import ServiceDispatcher
742-
dispatcher = ServiceDispatcher(self._config)
744+
# Reuse the server's shared ServiceDispatcher (feed engine attached
745+
# at startup) so direct iOS actions publish to the social feed too.
746+
dispatcher = getattr(self._server, "service_dispatcher", None)
747+
if dispatcher is None:
748+
from runtime.blockchain.services.service_dispatcher import ServiceDispatcher
749+
dispatcher = ServiceDispatcher(self._config) # cold fallback: works, but no feed
743750
result = await dispatcher.execute(action, params)
744751
return MobileResponse.ok(result)
745752
except KeyError as e:
@@ -750,6 +757,39 @@ async def execute_action(self, request: web.Request) -> web.Response:
750757
logger.error(f"Bridge action error: {e}", exc_info=True)
751758
return MobileResponse.error(str(e), 500)
752759

760+
# ─── Push notifications ─────────────────────────────────────────────────
761+
762+
async def register_push(self, request: web.Request) -> web.Response:
763+
"""POST /bridge/v1/push/register — {session_id, push_token} ->
764+
{registered: bool}. Persists the APNs device token so iOSPushChannel can
765+
fan out pushes. Actually SENDING pushes stays credential-gated (APNs
766+
.p8/key_id/team_id/bundle_id) — see HUMAN_ACTIONS.md."""
767+
try:
768+
body = await request.json()
769+
except Exception:
770+
return MobileResponse.error("invalid JSON")
771+
push_token = str(body.get("push_token", "")).strip()
772+
session_id = str(body.get("session_id", "")).strip()
773+
if not push_token:
774+
return MobileResponse.error("push_token required")
775+
linked = self._linked_wallets.get(session_id) or {}
776+
wallet = linked.get("address", "")
777+
try:
778+
from runtime.notifications.token_store import PushTokenStore
779+
db = self._server.react_loop.memory.db
780+
store = PushTokenStore(db)
781+
await store.register(
782+
push_token,
783+
session_id=session_id,
784+
wallet=wallet,
785+
platform="ios",
786+
bundle_id=str(body.get("bundle_id", "")),
787+
)
788+
except Exception as exc:
789+
logger.error("push token registration failed: %s", exc)
790+
return MobileResponse.error("registration failed", 500)
791+
return MobileResponse.ok({"registered": True})
792+
753793
# ─── Wallet ───────────────────────────────────────────────────────────
754794

755795
async def link_wallet(self, request: web.Request) -> web.Response:

0 commit comments

Comments
 (0)