From 12dcf4b4b04ca6571381d5094a46a96538698b18 Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:46:31 +0100 Subject: [PATCH 1/2] docs+release: EAS build profiles, a release runbook, and a README that tells the whole truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mobile/eas.json: development / preview / production profiles, remote version source with auto-incremented build numbers, network pinned per profile. - docs/RELEASE.md: the runbook — one-time EAS setup (the project id that also unlocks push), credentials, building, submitting, versioning, and what release builds change (no dev-launcher, so cold deep links just work). - README: the deploy story (publishing is a transaction — from the browser with any funded wallet, or from a persistent CLI keystore), and the one script reference that pointed at a name that never existed. --- README.md | 16 +++++++++- docs/RELEASE.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ mobile/eas.json | 30 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 docs/RELEASE.md create mode 100644 mobile/eas.json diff --git a/README.md b/README.md index c1d8276..d39a213 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,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 +155,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/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": {} + } +} From 72abd370871f55301ed1198f09c1a8a0f0e90b53 Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:52:14 +0100 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20the=20threat=20model=20=E2=80=94=20?= =?UTF-8?q?the=20adversary's=20view,=20with=20a=20bound=20per=20attacker?= =?UTF-8?q?=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight adversary classes (single signer through governance quorum, guardian, automation key, lying interfaces, the thief holding the phone), each with what it can actually take and the named test behind every claim; three end-to-end kill-chain walkthroughs against a shipped template; the accepted weaknesses restated adversarially; a residual-risk register that says what each remaining risk costs to realise; and an explicit out-of-scope that declares its assumptions instead of hand-waving them. Every cited test name is cross-checked against the suites. Linked from the README and the security model. --- README.md | 3 +- docs/SUI_SECURITY_MODEL.md | 3 +- docs/THREAT_MODEL.md | 223 +++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 docs/THREAT_MODEL.md diff --git a/README.md b/README.md index d39a213..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 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.