# Policy Vault — on-chain spending policies for session keys

Session keys v0 moved policy *signing* off the master key. The Policy Vault
moves policy *enforcement* on-chain: instead of trusting the client to obey
caps, the master parks ICP and cycles in a vault canister
(`22akh-eiaaa-aaaao-qqfta-cai`, controllers: Vittorio's NNS principal + the
FirstKey ops principal) and registers each session key with a Policy the
canister enforces before every spend. A compromised sub-agent client cannot
overspend — the vault verifies the Ed25519 signature and checks every rule
itself.

**v1 (2026-09-27): sessions, policies, spend counters, and the master survive
canister upgrades.** `pre_upgrade` snapshots the whole vault to stable memory
as candid; `post_upgrade` restores it. A snapshot decode failure traps, which
aborts the upgrade and keeps the old code + state — the vault is never
silently wiped. Verified on mainnet: a session was authorized, spent 30,000
e8s, survived a live upgrade with label/nonce/counters intact, and spent
again (nonce 2) after the upgrade. New `version` query reports the code
version (`"1.0.0"`).

## The model

- **Master**: one principal per vault (set at install). Only the master can
  `authorize_session`, `revoke_session`, `set_policy`, `rotate_master`,
  `sweep_cycles_to_ledger`, and the `master_withdraw_*` escape hatches.
  Sovereignty: the vault can never trap the master's funds — the escape
  hatches bypass policy entirely.
- **Session key**: a raw 32-byte Ed25519 public key (NOT a principal).
  It spends by signing an *action envelope*; anyone may submit it to `act`
  because the signature is the authentication.
- **Funding**: the master sends ICP (ICP ledger `icrc1_transfer` to the vault
  principal) and/or cycles (cycles-ledger `deposit` to the vault's account —
  or `sweep_cycles_to_ledger(n)` to move the canister's own raw cycles into
  its ledger account). `vault_balances` reports both.

## Policy fields

`allowed_destinations`: principal texts, or `"*"` for any. Caps: `0` means
*disabled* for per-call caps, *unlimited* for daily/total caps.
`not_after_ns`: `0` = never expires. Spend counters track `amount` only;
ledger fees come out of the vault on top.

| field | meaning |
|---|---|
| `label` | human tag, e.g. `"deploy-bot-2026-09-27"` |
| `allowed_destinations` | vec of principal texts, or `["*"]` |
| `max_icp_per_call_e8s` / `max_cycles_per_call` | per-action ceiling (0 = that asset disabled) |
| `daily_icp_cap_e8s` / `daily_cycles_cap` | rolling 24h budget (0 = unlimited) |
| `total_icp_cap_e8s` / `total_cycles_cap` | lifetime budget (0 = unlimited) |
| `not_after_ns` | expiry, nanos since epoch (0 = never) |
| `revoked` | set by `revoke_session`; record kept for audit |

## Action envelope (signed bytes, big-endian)

```
b"firstkey-policy-act/1" || kind u8 || plen u8 || principal[plen]
  || subaccount[32] (zeros = none) || amount u64 || nonce u64 || issued_at_ns u64
```

kind `0x01` = `icp_transfer` (amount in e8s), `0x02` = `cycles_transfer`
(amount in cycles). Nonces must strictly increase per session (replay
protection); `issued_at_ns` must be within 15 min of canister time.

## Headless flow

```bash
curl -sO https://firstkey.io/deploy-pack/scripts/fk_policy.py
VAULT=22akh-eiaaa-aaaao-qqfta-cai

# 1. Mint a session key (master side; the SEED never leaves you)
python3 fk_policy.py mint-session   # -> seed_hex (secret), pubkey_hex

# 2. Register it with a policy (master calls; example: 0.0005 ICP/call max,
#    0.001 ICP/day, one destination, ICP only)
icp canister call -n ic $VAULT authorize_session \
  --candid policy-vault.did --args-file policy_args.txt

# 3. Fund the vault (master side)
icp canister call -n ic ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer \
  "(record { to = record { owner = principal \"$VAULT\" }; amount = 150_000 : nat })"

# 4. Spend as the session key (sub-agent side; only the seed needed)
J=$(python3 fk_policy.py sign-act --seed-hex $SEED --kind icp \
    --to <dest-principal> --amount 40000 --nonce 1)
# -> submit (pubkey_hex, envelope_hex, signature_hex) to $VAULT `act`
# -> Ok(block_index) or Err("amount exceeds per-call policy cap" | ...)

# 5. Kill switch (master side)
icp canister call -n ic $VAULT revoke_session \
  --candid policy-vault.did --args '((blob "...32 bytes..."))'
```

`fk_policy.py` is stdlib-only python3 (pure-python Ed25519, cross-checked
against the JS noble implementation and the canister's ed25519-dalek).
`policy-vault.did` ships next to this reference.

## What the vault checks, in order

1. Session known, not revoked, not expired
2. Ed25519 signature valid over the exact envelope bytes
3. Nonce fresh (strictly increasing), timestamp within skew window
4. Amount within the per-call cap for that asset
5. Destination in `allowed_destinations`
6. Daily and lifetime caps not breached (reservation made *before* the
   ledger call; rolled back if the transfer fails)

## v1 limits (was: v0 limits)

- Transfers only (ICP + cycles) — no arbitrary canister calls yet.
- ~~State is in-memory: a canister upgrade wipes sessions; the master
  re-authorizes afterwards.~~ **v1: sessions, policies, counters, and master
  persist across upgrades** via a candid stable-memory snapshot
  (`pre_upgrade`/`post_upgrade`); decode failure aborts the upgrade instead
  of wiping. Verified on mainnet 2026-09-27.
- One vault = one master. Multi-master / threshold is future work.

Verified on mainnet 2026-09-27: 8/8 policy paths exercised against ledger
state (valid ICP + cycles transfers executed; per-call, daily, allowlist,
replay, and revoked-key denials all rejected; master escape hatches drained
the vault to exactly zero).
