diff --git a/README.md b/README.md index c1d8276..37f1dc0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ treasury sits behind. | **An agent role** | `PROPOSER` can open a payment and nothing else. A compromised automation key produces a queue nobody approved, not a withdrawal. | Details, with the test that proves each invariant, in -[docs/SUI_SECURITY_MODEL.md](docs/SUI_SECURITY_MODEL.md). +[docs/SUI_SECURITY_MODEL.md](docs/SUI_SECURITY_MODEL.md); the adversary-first analysis with +loss bounds per attacker class in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md). ## Repository layout @@ -84,7 +85,7 @@ genuinely breaks. cd sui sui move build sui move test # 75 tests: the engine vectors and the end-to-end suite -scripts/verify-local.sh # publish to a throwaway local network and drive it +scripts/verify.sh # everything CI runs, locally # the SDK cd ../sdk @@ -155,6 +156,20 @@ app, the SDK or the backend hardcodes one. 4. **Governance** follows the same path with owner approvals. Anything that gives up a control waits out the policy-change delay, is vetoable, and is refused in Lockdown. +## Deploying to a network + +The Move package ships compiled next to the web app, and **publishing is a transaction like any +other** — no privileged machine required: + +- **From the browser**: open `/app` on a network with no package and the page offers + *Publish Skur* — your connected wallet signs, pays the ~0.4 SUI storage, and receives the + `UpgradeCap`. Whoever publishes holds the upgrade rights, explicitly. +- **From the CLI**: fund an address in your `sui client` keystore and `sui client publish` from + [sui/](sui/). Keep that keystore somewhere permanent. + +Then put the package id in each surface's env (`NEXT_PUBLIC_SKUR_PACKAGE_ID`, +`EXPO_PUBLIC_SKUR_PACKAGE_ID`, the indexer's `SKUR_PACKAGE_ID`) and restart. + ## Status Built and under test: the Move core with all seven proposal kinds, the SDK over gRPC and GraphQL, diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..fb37090 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,79 @@ +# Releasing the mobile apps + +The phones build through [EAS](https://docs.expo.dev/build/introduction/). Everything the build +needs is in the repo except two things that are account-bound and deliberately not committed: the +EAS project id and the store credentials. + +## One-time setup + +```bash +cd mobile +npx eas login # the Expo account that owns the app +npx eas init # writes extra.eas.projectId into app.json — commit that change +``` + +`extra.eas.projectId` is also what remote push notifications key on: without it, +`getExpoPushTokenAsync` refuses and Settings says so. Setting it once fixes builds and push +together. + +Credentials (Apple certificates, Android keystore) are created and stored by EAS on first build — +answer the prompts, let it manage them. + +## Profiles + +Defined in [mobile/eas.json](../mobile/eas.json): + +| Profile | For | Notes | +|---|---|---| +| `development` | day-to-day work | dev client; pair with `npx expo start` | +| `preview` | hand-installable test builds | internal distribution, no store | +| `production` | the stores | `autoIncrement` bumps build numbers remotely | + +Every profile pins `EXPO_PUBLIC_SKUR_NETWORK`. Before a production build, also set the published +package id and the deployed indexer URL for that network: + +```bash +# in mobile/eas.json, production.env: +"EXPO_PUBLIC_SKUR_PACKAGE_ID": "0x…", +"EXPO_PUBLIC_SKUR_API": "https://api.your-deployment…" +``` + +(Dev builds read `.env` instead, which is gitignored.) + +## Building + +```bash +cd mobile +npx eas build --platform ios --profile production +npx eas build --platform android --profile production +# or both: --platform all +``` + +Store submission, once builds exist: + +```bash +npx eas submit --platform ios +npx eas submit --platform android +``` + +## Versioning + +- `expo.version` in `app.json` is the human version (1.0.0) — bump it per release, in git. +- iOS `buildNumber` / Android `versionCode` are managed remotely (`appVersionSource: "remote"` + + `autoIncrement`): every production build gets the next number without a commit. + +## What release builds change + +- No expo-dev-launcher: cold `skur://` deep links go straight to the app (the dev-only + launcher crash documented in the memory notes does not exist in release). +- `EXPO_PUBLIC_*` come from the profile, not `.env`. +- The splash, icons and the brand mark are already production assets — nothing to swap. + +## Before shipping, every time + +```bash +npm run verify # from the repo root: Move tests, SDK, server, web, mobile — 11 gates +``` + +And the standing honesty check: Skur has **no independent audit**. The store listing and the app +itself say so; keep it that way until it stops being true. diff --git a/docs/SUI_SECURITY_MODEL.md b/docs/SUI_SECURITY_MODEL.md index 73fd18e..e0c9efc 100644 --- a/docs/SUI_SECURITY_MODEL.md +++ b/docs/SUI_SECURITY_MODEL.md @@ -1,6 +1,7 @@ # Skur security model (Sui / Move) -What each control is, where it is enforced, and which test proves it. +What each control is, where it is enforced, and which test proves it. The adversary-first view — +who the attackers are and what each class can actually take — is [THREAT_MODEL.md](THREAT_MODEL.md). The claim is bounded and worth stating before anything else: **Skur adds independent, programmable controls that can limit or delay damage even when authorized credentials are misused.** It does not diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..a1bd9c6 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,223 @@ +# Skur threat model + +The adversary's view of the system. The companion document, +[SUI_SECURITY_MODEL.md](SUI_SECURITY_MODEL.md), walks the controls one by one and names the test +behind each; this one starts from the attacker and asks what each class of adversary can actually +take, delay, or break — with the bound stated as a number wherever one exists. + +Every claim here traces to a named test in [`sui/tests/`](../sui/tests/) or a stated assumption. +Where the honest answer is "this is not defended", it says so. + +--- + +## 1. What is being protected + +| Asset | Where it lives | Loss mode | +|---|---|---| +| Treasury funds | `Balance` inside the shared `Vault` object | theft, freeze-out | +| Policy integrity | `Policy` struct inside the vault | weakening that enables later theft | +| Roster integrity | the members table | role capture, guardian displacement | +| Operational continuity | the vault's mode | griefing via unjustified Lockdown | + +Non-assets, deliberately: the vault's *existence and contents are public*. Reads are not gated and +confidentiality is not claimed. An adversary learning balances, policy or the queue is the system +working as designed. + +## 2. Trust assumptions + +What the model stands on. Break one of these and the guarantees below shrink accordingly. + +1. **Sui consensus is honest-majority and live.** Equivocation, reorgs deep enough to unwind + finality, or total censorship are out of scope; they break every object on the chain, not Skur + specifically. +2. **`sui::clock` is approximately correct.** Delays and windows are measured against the shared + clock object. Validator-level clock manipulation is out of scope. +3. **Move's type system holds.** `Balance` cannot be forged; abilities are enforced; a struct + without `store` cannot be smuggled out. A Move VM soundness bug is out of scope. +4. **The secure element does its job.** On mobile, the signing key is generated in and released by + the device keystore behind a biometric. Extraction from a locked, un-rooted device is out of + scope; a rooted device demotes the key to "hot key an attacker can use while holding the + phone" — which the model below already prices in as "one compromised signer". +5. **At least one guardian key is held independently.** The product tells users this at every + turn: a guardian key on the same phone as a signer key collapses the two planes. The model + assumes the advice was taken; §6 shows what remains when it was not. + +Explicitly **not** assumed: an honest backend, an honest frontend, an honest wallet UI, honest +co-signers, or an honest majority of owners below the governance threshold. + +## 3. Adversary classes and their bounds + +The bound for money-out attacks is always the same four-term expression, computed per asset: + +> **max loss = min(per-tx cap, day allowance remaining, envelope remaining) per payment, +> summed until the envelope latches — plus nothing after the latch.** + +The `computeMaxLoss` engine in the SDK (and the Security screen in every client) evaluates this +honestly, including the anchored-window caveat (§5). + +### A. One compromised treasury signer + +*Capability:* open payments; contribute one approval; execute if also executor. + +- Alone, clears only tiers with `approvals = 1` — the routine tier in shipped templates — so each + payment is bounded by `low_max`, and the run is bounded by the day cap and the envelope. +- Cannot raise its own authority: governance needs `governance_threshold` owners + (`counts_must_satisfy_the_policy`), and an approval recorded while holding the role stops + counting the moment the role is gone (`a_removed_signers_approval_stops_counting`). +- Cannot replay an approval (`an_approval_cannot_be_replayed`) or double-execute + (`a_payment_cannot_execute_twice`). +- Sending to itself or a mule hits recipient trust: an unregistered address escalates every + payment (`an_unknown_recipient_is_escalated_without_being_registered`); registering one starts + it restricted and waits out activation (`a_new_recipient_cannot_skip_activation`, + `registering_a_recipient_starts_it_at_the_restrictive_default`). + +**Bound:** the routine drip, cut off by velocity (`splitting_a_drain_does_not_beat_the_daily_cap`, +`velocity_pressure_escalates_mid_drain`) and latched by the envelope +(`crossing_the_loss_envelope_freezes_the_vault_instead_of_paying`). + +### B. A full approval quorum compromised (k of n signers) + +*Capability:* clear any tier's approvals. + +- High and critical tiers still **wait** (`delay_high`, `delay_critical`) — the delay exists to be + a veto window, and a guardian veto both stops the payment and raises the posture + (`a_guardian_veto_stops_a_critical_payment_and_raises_the_posture`). +- Critical payments additionally need a guardian confirmation the attacker does not have + (`one_signer_cannot_clear_a_high_tier_payment` generalises; `requirements_follow_the_tier`). +- The hard-block line cannot be approved around (`the_hard_block_cannot_be_approved_around`), and + the first payment that would cross the envelope trips Lockdown instead of paying. +- Execution re-derives requirements against **live** state and applies the stricter of pinned vs + live — a quorum assembled yesterday does not help against a posture raised today. + +**Bound:** everything below the high tier immediately; the high tier after its delay if no +guardian acts; the critical tier never without a guardian. Cumulative total still enveloped. + +### C. An owner coalition below the governance threshold + +*Capability:* none that matters. Governance actions count owner approvals against +`governance_threshold`; below it, proposals sit in the queue. Rejections are symmetric and final +(`as_many_rejections_as_approvals_turns_a_payment_down`, `a_rejected_payment_cannot_execute`). + +### D. A governance quorum turned malicious (or coerced) + +*Capability:* propose any policy or roster change. + +This is the strongest insider adversary the design engages, and the firewall is built for it: + +- **Tightening is instant; loosening is not.** Every change is diffed field by field, including + disguises — zeroing a threshold to disable it, lengthening proposal lifetimes, approving a new + asset (`zeroing_the_envelope_is_a_reduction`, `a_longer_proposal_lifetime_is_a_reduction`, + `approving_a_new_asset_is_a_reduction`, `weakening_a_cap_to_zero_is_a_reduction`). +- Any reduction waits `policy_change_delay` and **any single guardian vetoes it** + (`a_guardian_can_veto_a_weakening`); in Lockdown it cannot even be proposed + (`lockdown_stops_new_payments_from_being_opened` extends to proposals by mode gate). +- Displacing the guardian from the roster is itself a governance action with the same delay and + the same veto — the coalition must win the race against the very key whose job is noticing. + +**Bound:** a quorum plus *every* guardian asleep for the full delay. That conjunction is the +residual risk, stated in §6. + +### E. A compromised guardian key + +*Capability:* freeze, veto, confirm, block recipients, open recoveries. + +- **No withdrawal path exists** — the contract refuses the combination at the type level + (`a_guardian_cannot_hold_a_treasury_role`); a guardian cannot open, approve or execute a payment + (`a_guardian_cannot_open_a_payment`). +- The damage mode is **griefing**: unjustified Lockdown, vetoes, nuisance recoveries. Lockdown + exit deliberately needs owners + a guardian + time (`leaving_lockdown_takes_owners_a_guardian_and_time`, + `owners_alone_cannot_lift_a_freeze`); a hostile recovery waits `recovery_delay` and **any one + owner cancels it** (`any_owner_can_cancel_a_recovery`, `one_owner_can_reject_a_recovery`). + +**Bound:** denial of service for bounded periods; zero direct theft. Multi-guardian templates +dilute even the griefing. + +### F. A compromised automation key (`PROPOSER`) + +*Capability:* open proposals. Only that (`a_proposer_can_only_propose`, +`an_agent_cannot_approve_its_own_proposal`, `an_agent_cannot_execute`, `an_agent_cannot_reject`). + +**Bound:** a queue of unapproved proposals — noise, each item expiring on its TTL +(`a_proposal_expires_and_cannot_be_revived`). + +### G. A malicious frontend, backend, or wallet UI + +*Capability:* lie to humans; it cannot lie to the chain. + +- Every requirement is computed on chain; `preview_transfer` is the same code `execute_transfer` + runs (`a_preview_matches_what_the_vault_will_demand`), so a cooked preview cannot make the vault + accept what it would refuse. +- The indexer/notification backend is **advisory by design** — it can see across vaults and wake + humans up; it cannot approve, delay, or waive anything (see `server/src/advisory.ts`'s header). +- What a lying UI *can* do is the classic wallet-drainer move: display payment X while submitting + payment Y for signature. Skur narrows this — V1 transactions are coin transfers only, no + arbitrary calls — and the mobile app computes its review screen from chain reads, but a user who + signs blind on a compromised device is signing blind. Stated as residual in §6. + +### H. A thief with the unlocked phone + +*Capability:* whatever that device's key can do — i.e. exactly adversary A (or E, if the owner +ignored the hot-key warning), gated per action by the biometric the thief does not have. The +keystore releases the key one action at a time; there is no session token to steal. Losing the +device entirely is the **recovery** path: roles move to a new key after `recovery_delay`, any +owner can cancel, the vault exits Elevated (`recovery_moves_the_roles_and_leaves_the_vault_elevated`, +`recovery_cannot_skip_its_delay`). + +## 4. Kill-chain walkthroughs + +Three attacks, end to end, against a shipped startup-template vault +(1/2/2 approvals, 1 guardian, 10 % envelope per day). + +**The drip.** A steals one signer key, scripts routine-max payments to a fresh address. Payment 1: +unknown recipient escalates it out of the routine tier → needs a second approval A doesn't have. +A registers the address instead: activation delay, restricted default, visible in every client's +queue with a push notification out. Dead before money moves. + +**The smash.** B compromises both signers, proposes 24 % of holdings. Share-of-holdings escalates +to critical (`share_of_treasury_escalates_on_its_own`) → needs the guardian; without it, never +executes. B tries 9 % instead: high tier, waits a day on the clock in front of every member and +the guardian's phone. Repeat attempts stack into the envelope; the attempt that would cross 10 % +latches Lockdown, keeping ~90 % regardless of anyone reacting at all. + +**The quiet coup.** D's quorum proposes `approvals_critical: 2 → 1` dressed inside an otherwise +tightening change. The field diff flags the reduction; the proposal waits `policy_change_delay` +flagged *weakens the vault* in every surface; one guardian veto ends it and the attempt is +permanent, attributed chain history. + +## 5. Accepted weaknesses (design positions) + +Inherited from the security model, restated adversarially: + +- **Anchored windows**: a patient attacker straddling a bucket boundary gets up to two days' + allowance in quick succession. The envelope and per-tx caps still bound it; the max-loss screen + prices it in. +- **Envelope basis is window-open balance**: a deposit mid-window doesn't raise the allowance — + the safe direction — but the symmetric surprise is that it doesn't. +- **Per-asset exposure, no portfolio oracle**: an attacker can prefer the asset where their + amount is a smaller share. Caps are per-asset for exactly this reason; set them that way. +- **Latching is not refunding**: the breaker stops the crossing payment, not its predecessors. + +## 6. Residual risk register + +| # | Residual | Needed for it | Mitigation posture | +|---|---|---|---| +| R1 | Full theft via governance | owner quorum **and** every guardian passive for the whole delay | more guardians, longer `policy_change_delay`, guardians on hardware off-site | +| R2 | Blind signing on a rooted/compromised device | user device compromise + user signs without reading | V1's transfer-only surface, chain-computed reviews; ultimately a device-security boundary | +| R3 | Routine-tier drip within all caps | one signer key, patient | set `approvals_low: 2` where throughput allows; watch notifications | +| R4 | Guardian griefing | one guardian key | multi-guardian templates; recovery + owner-cancel as the counterweight | +| R5 | Vault throughput ceiling | none (physics of one shared object) | shard into several vaults | +| R6 | Everything in §2's assumptions | breaking Sui, Move, or the secure element | out of scope by declaration, not by hand-waving | + +## 7. Out of scope, explicitly + +Validator-level attacks on Sui; Move VM soundness; secure-element extraction; side channels on +the user's hardware; social engineering that convinces a *quorum plus the guardians* to approve a +theft on purpose (no mechanism survives its own operators conspiring at full strength — the design +goal is that nothing *less* than that conjunction suffices, and §3 shows the price of each lesser +coalition). + +## 8. Standing honesty + +No independent audit has been performed. This document is the map the auditor should try to tear; +until one has, do not put real value behind Skur. The apps, the site and the README carry the same +sentence on purpose. diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 0000000..ceddd57 --- /dev/null +++ b/mobile/eas.json @@ -0,0 +1,30 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "env": { + "EXPO_PUBLIC_SKUR_NETWORK": "testnet" + } + }, + "preview": { + "distribution": "internal", + "env": { + "EXPO_PUBLIC_SKUR_NETWORK": "testnet" + } + }, + "production": { + "autoIncrement": true, + "env": { + "EXPO_PUBLIC_SKUR_NETWORK": "testnet" + } + } + }, + "submit": { + "production": {} + } +}