# 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"`).

**v2 (2026-09-28): session keys can invoke arbitrary canister methods within
an `allowed_calls` policy** (kind `0x03` envelopes via the new `act_call`
method). Each `AllowedCall` rule names a canister principal (or `"*"`) and a
method (or `"*"`), plus a per-call cap on attached cycles. Attached cycles
count against the session's cycles budgets (daily/total); the per-call
*transfer* caps don't apply to calls — the rule's `max_cycles_attached` is the
per-call ceiling. The call executes as the vault canister itself, so
master-only methods on any canister (including the vault) stay unreachable to
session keys. v1 snapshots migrate on upgrade (v1 policies get
`allowed_calls: []`). Verified on mainnet: a v1 session survived the live
v1→v2 upgrade with label/counters intact; allowed call executed (faucet
`stats`, reply matched a direct call); wrong-method, wrong-canister,
cycles-over-cap, replay, daily-cap, and revoked-key denials all rejected; a
1M-cycle-attached call executed with counters recorded exactly.

**v3 (2026-09-28): per-destination spending caps** (`destination_caps` on the
policy). Each entry names one destination principal (or `"*"` as a fallback
for destinations with no exact entry) with its own per-call / daily /
lifetime caps per asset. Per-destination caps TIGHTEN ONLY: a transfer must
satisfy both the global session caps and the destination's caps; no entry for
a destination = global caps only (v2 behavior). Per-destination spend
counters are tracked on-chain, survive upgrades via the v3 snapshot, and are
visible in `get_session` (`dest_spent`); `set_policy` does not reset them.
`authorize_session`/`set_policy` validate the entries (parseable principals,
no duplicates). v2 snapshots migrate on upgrade (v2 policies get
`destination_caps: []`). Verified on mainnet: a v2 session survived the live
v2→v3 upgrade with label/counters intact and `destination_caps: []`; after
`set_policy`, 9/9 transfer paths behaved — valid transfers within dest caps
executed (blocks on the ICP ledger), per-destination per-call/daily/lifetime
denials fired while the global caps allowed the same amounts, the `"*"`
fallback applied to an unlisted destination (counters keyed by the actual
destination), the destination allowlist and global per-call cap still
enforced, and `authorize_session` rejected a bad destination principal and a
duplicate entry. Test sessions revoked; vault drained to (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 |
| `allowed_calls` | vec of `AllowedCall { canister: text; method: text; max_cycles_attached: nat64 }` — `"*"` wildcards either; empty = call actions disabled |
| `destination_caps` | vec of `DestinationCap { destination: text; max_icp_per_call_e8s: nat64; max_cycles_per_call: nat64; daily_icp_cap_e8s: nat64; total_icp_cap_e8s: nat64; daily_cycles_cap: nat64; total_cycles_cap: nat64 }` — `destination` is a principal text or `"*"` (fallback for destinations with no exact entry); per-call 0 = that asset disabled for this destination, daily/total 0 = unlimited at this level; **tighten only** (the transfer must also satisfy the global caps); empty = no per-destination caps |

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

Transfers (kind `0x01`/`0x02`):

```
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).

Call actions (kind `0x03`, submitted to `act_call`):

```
b"firstkey-policy-act/1" || 0x03 || canister_plen u8 || canister[canister_plen]
  || mlen u8 || method[mlen] || cycles_attached u64 || alen u32 || args[alen]
  || nonce u64 || issued_at_ns u64
```

`args` are pre-encoded candid bytes (`fk_policy.py sign-call` defaults to the
empty-args encoding `4449444c0000`). The vault returns the raw candid reply
bytes. 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)
#    Generate the candid Policy record headlessly (v3 supports --dest-cap and
#    --allow-call; amounts are integers, per-call 0 = disabled, daily/total
#    0 = unlimited):
#    python3 fk_policy.py policy-candid --label deploy-bot --dest <dest-principal> \
#      --max-icp-per-call 100000 --daily-icp 1000000 --total-icp 5000000 \
#      --dest-cap <dest-principal>,30000,0,60000,100000,0,0 > policy_args.txt
icp canister call -n ic $VAULT authorize_session \
  --candid policy-vault.did --args "(blob \"<pubkey_hex>\", $(cat 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..."))'

# 6. Call actions (v2): allow a (canister, method) pair in the policy, then
#    sign a call envelope and submit to act_call (returns raw reply bytes)
#    policy: allowed_calls = vec { record {
#      canister = "3l667-lyaaa-aaaam-ajkqa-cai"; method = "stats";
#      max_cycles_attached = 0 : nat64 } }
J=$(python3 fk_policy.py sign-call --seed-hex $SEED \
    --canister 3l667-lyaaa-aaaam-ajkqa-cai --method stats --nonce 1)
# -> submit (pubkey_hex, envelope_hex, signature_hex) to $VAULT `act_call`
# -> Ok(blob <raw candid reply>) or Err("call target not in policy allowlist" | ...)
```

`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. Transfers: amount within the per-call cap for that asset; destination in
   `allowed_destinations`; amount within the destination's `destination_caps`
   entry too (exact match, then the `"*"` fallback — tighten only; no entry =
   global caps only). Calls (`act_call`): a matching `allowed_calls` rule
   (canister + method); attached cycles within the rule's
   `max_cycles_attached`
5. Daily and lifetime caps not breached — global AND per-destination
   (reservation made *before* execution; rolled back if the ledger transfer /
   call fails). Attached cycles count against the cycles budgets.
   (`destination_caps` do not apply to call actions.)

## v3 limits

- One vault = one master. Multi-master / threshold is future work.
- `destination_caps` apply to transfers only, not to `act_call` actions
  (call targets are governed by `allowed_calls` rules).
- Attached cycles are reserved against the cycles budget at authorization;
  cycles the callee doesn't accept are refunded by the system but NOT credited
  back to the budget (conservative accounting).
- Calls run as the vault canister itself: attached cycles come from its raw
  cycles balance (not its cycles-ledger account).

## 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.
- **v2 (2026-09-28): call actions are live** — see above. 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).

Verified on mainnet 2026-09-28 (v2): v1→v2 upgrade with a live session —
session, label, and counters survived with `allowed_calls: []`; after
`set_policy`, an allowed call executed (faucet `stats`, reply matched a direct
call); wrong-method, wrong-canister, cycles-over-cap, replay, daily-cap, and
revoked-key denials all rejected; a 1M-cycle-attached call executed with
counters recorded exactly; `version()` → `"2.0.0"`.
