Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,10 @@ and are unaffected. Adjust the period via `idle_lock_minutes` in

## Mobile WebUI (v0.9+)

`kpot serve` exposes a read-only web interface bound to `127.0.0.1`,
designed for phone access via SSH tunnel + VPN.
`kpot serve` exposes a read-only web interface. It binds to
`127.0.0.1` by default for phone access via SSH tunnel. Advanced users
can bind a specific VPN/Tailscale interface IP with `--bind`; wildcard
binds such as `0.0.0.0` and `::` are refused.

```bash
# On the host (e.g. your home server):
Expand All @@ -510,7 +512,8 @@ Features:

Security:

- Listens on `127.0.0.1` only; no `--bind` flag (SSH tunnel is the auth boundary)
- Default bind is `127.0.0.1`; `--bind` is only for a specific trusted VPN/Tailscale interface IP
- Wildcard binds (`0.0.0.0`, `::`) are refused
- Read-only — no edit endpoints, REPL stays the edit surface
- Session cookies are HttpOnly + SameSite=Strict
- Login rate-limited (3 fails / 60s → 30s lockout)
Expand Down
9 changes: 4 additions & 5 deletions cmd/kpot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,11 @@ func cmdConfigShow(cfg config.Config) error {

// cmdServe starts the read-only WebUI for a vault. Usage:
//
// kpot serve <name|file> [--port 8765] [--idle 30] [--no-cache]
// kpot serve <name|file> [--bind ADDR] [--port 8765] [--idle 30] [--no-cache]
//
// The daemon binds 127.0.0.1 only — there's no `--bind` flag on
// purpose. Phone access is meant to go through an SSH tunnel; binding
// to 0.0.0.0 would expose plaintext HTTP to the LAN, contradicting
// docs/security.md's threat model.
// The daemon binds 127.0.0.1 by default. `--bind` exists for a
// specific VPN/Tailscale interface IP only; wildcard binds are rejected
// because the WebUI is plain HTTP and relies on a trusted transport.
func cmdServe(args []string, cfg config.Config) error {
var (
path string
Expand Down
12 changes: 6 additions & 6 deletions docs/manual.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>kpot ユーザーマニュアル v0.5.0</title>
<title>kpot ユーザーマニュアル v0.9.0</title>
<style>
:root {
--bg:#0d1117; --bg2:#161b22; --bg3:#21262d; --border:#30363d;
Expand Down Expand Up @@ -90,8 +90,8 @@
<article>
<h1 id="kpot">kpot ユーザーマニュアル</h1>
<blockquote>
<p><strong>バージョン</strong>: 0.5.0<br />
<strong>最終更新</strong>: 2026-04-27<br />
<p><strong>バージョン</strong>: 0.9.0<br />
<strong>最終更新</strong>: 2026-04-29<br />
<strong>リポジトリ</strong>: https://github.com/Shin-R2un/kpot</p>
</blockquote>
<hr />
Expand Down Expand Up @@ -1072,12 +1072,12 @@ <h4 id="_27">改ざん検出</h4>
# exit code: 3
</code></pre>
<hr />
<p><em>このマニュアルは kpot v0.5.0 に基づいています。</em><br />
<p><em>このマニュアルは kpot v0.9.0 に基づいています。</em><br />
<em>最新情報は https://github.com/Shin-R2un/kpot を参照してください。</em></p>
</article>
</div>
<footer>
<p>kpot v0.5.0 · MIT License · <a href="https://github.com/Shin-R2un/kpot" target="_blank">github.com/Shin-R2un/kpot</a></p>
<p>kpot v0.9.0 · MIT License · <a href="https://github.com/Shin-R2un/kpot" target="_blank">github.com/Shin-R2un/kpot</a></p>
</footer>
</body>
</html>
</html>
19 changes: 12 additions & 7 deletions docs/manual.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# kpot ユーザーマニュアル

> **バージョン**: 0.8.0
> **最終更新**: 2026-04-29
> **バージョン**: 0.9.0<br>
> **最終更新**: 2026-04-29<br>
> **リポジトリ**: https://github.com/Shin-R2un/kpot
>
> v0.5.0 → v0.8.0 の追加点:
> v0.5.0 → v0.9.0 の追加点:
> - **v0.6**: REPL コンテキスト UX (`cd` / `show` / `fields` / `cp` / `set` / `unset`)
> - **v0.6.1**: ノート名で Unicode 対応 (日本語 / キリル / ギリシャ等)
> - **v0.7**: `vault_dir` + `default_vault` でベアネーム解決と既定 vault
> - **v0.8**: `kpot config init/show/path` サブコマンド
> - **v0.9**: `kpot serve` read-only mobile WebUI

---

Expand Down Expand Up @@ -577,10 +578,12 @@ vault_dir = "/home/shin/.kpot"

### 4.7 serve サブコマンド (v0.9+)

スマホからの read-only WebUI。SSH トンネル + VPN 経由でアクセスする前提。
スマホからの read-only WebUI。既定は SSH トンネル向けに `127.0.0.1`
へ bind します。上級者向けに、特定の VPN/Tailscale インターフェース IP
だけを `--bind` で指定できます。

```
kpot serve <name|file> [--port 8765] [--idle 30] [--no-cache]
kpot serve <name|file> [--bind ADDR] [--port 8765] [--idle 30] [--no-cache]
```

```bash
Expand All @@ -601,7 +604,9 @@ ssh -L 8765:127.0.0.1:8765 user@fw0
- OS keychain にキャッシュがあれば daemon 起動時に silent unlock (`--no-cache` で無効化可能)

セキュリティ:
- `127.0.0.1` のみ bind (`--bind 0.0.0.0` フラグは意図的に無し)
- 既定は `127.0.0.1` bind (SSH トンネルが transport 境界)
- `--bind` は特定の VPN/Tailscale インターフェース IP 用
- wildcard bind (`0.0.0.0`, `::`) は拒否
- 完全 read-only (ノート編集は引き続き REPL/CLI)
- session cookie は HttpOnly + SameSite=Strict
- login rate limit (3 fail / 60s → 30s lockout)
Expand Down Expand Up @@ -1145,5 +1150,5 @@ Wrong passphrase, or the file is corrupted

---

*このマニュアルは kpot v0.5.0 に基づいています。*
*このマニュアルは kpot v0.9.0 に基づいています。*<br>
*最新情報は https://github.com/Shin-R2un/kpot を参照してください。*
12 changes: 10 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> **Audience**: anyone deciding whether to trust kpot with secrets,
> reviewers, and security researchers about to file a report.
>
> **Scope**: this document describes what kpot v0.5.x is designed to
> **Scope**: this document describes what kpot v0.9.x is designed to
> defend against, what it explicitly is **not**, and the rationale
> for each crypto / OS choice. The vulnerability reporting flow is
> in [SECURITY.md](../SECURITY.md).
Expand Down Expand Up @@ -84,6 +84,12 @@ passphrase.
`read` writes to stdout (your choice — convenient but visible);
prefer `copy` for "show but don't display."

`kpot serve` is read-only and uses per-session idle locks. It binds
`127.0.0.1` by default for SSH-tunnel access. `--bind` is allowed only
for a specific trusted VPN/Tailscale interface IP; wildcard binds such
as `0.0.0.0` and `::` are refused. The WebUI is plain HTTP, so the
transport boundary must be loopback, SSH, or VPN.

### 4. Vault directory metadata exposure

Since v0.7, bare-name vault arguments resolve under
Expand Down Expand Up @@ -217,7 +223,7 @@ What's planned (no fixed timeline, but on the roadmap):
Defaults (`memory=64 MiB, iterations=3`) target ~0.5 s on a 2020-era
laptop. The parameters are currently fixed at the values stated above;
auto-calibration via a `--argon2-target` flag is planned but **not yet
shipped**, so do not rely on it being available in v0.5.x.
shipped**, so do not rely on it being available in v0.9.x.

### Why BIP-39 for the recovery seed?

Expand Down Expand Up @@ -281,4 +287,6 @@ coordinated disclosure on a 60–90 day timeline.

## Changelog of this document

- **2026-04-29**: Updated scope to v0.9.x and documented `kpot serve`
bind-address rules.
- **2026-04-28**: Initial threat model for v0.5.x.
34 changes: 21 additions & 13 deletions docs/serve.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# `kpot serve` — mobile WebUI (v0.9+)

Read-only web interface for accessing a kpot vault from a smartphone via
SSH tunnel. Designed for the workflow:
Read-only web interface for accessing a kpot vault from a smartphone.
The default access path is an SSH tunnel to a loopback-only daemon; an
advanced direct-VPN mode can bind one specific VPN/Tailscale interface
IP. Designed for the workflow:

> 「外出先のスマホから、自宅サーバの kpot vault のパスワードをコピーして
> ブラウザに貼りたい」
Expand All @@ -11,9 +13,11 @@ SSH tunnel. Designed for the workflow:
`kpot serve` ships under the same threat model as the rest of kpot
(`docs/security.md`), with two specific properties:

- **Listens on `127.0.0.1` only.** There is no `--bind` flag. The plain-
HTTP boundary is the SSH tunnel, not TLS. Exposing the port on the
LAN would contradict the "compromised host out of scope" boundary.
- **Binds `127.0.0.1` by default.** The plain-HTTP boundary is the SSH
tunnel, not TLS.
- **`--bind` is advanced mode for one trusted interface IP.** Use it
only for a specific VPN/Tailscale address such as `10.0.0.1` or
`100.x.y.z`. Wildcard binds (`0.0.0.0`, `::`) are refused.
- **Read-only.** No endpoint mutates the vault. Edits remain a REPL/CLI
responsibility because the vault format has no file lock yet — REPL
+ `serve` writing concurrently would race.
Expand All @@ -36,7 +40,7 @@ session is active, the attacker can read every note. Mitigations:
│ ssh -L 8765:127.0.0.1:8765 user@fw0
http://localhost:8765/ kpot serve <vault>
└── 127.0.0.1:8765 only
└── 127.0.0.1:8765 by default
```

## Quick start
Expand All @@ -56,7 +60,8 @@ phone side. Defaults:

| Flag | Default | Meaning |
|---|---|---|
| `--port` | `8765` | TCP port on `127.0.0.1` |
| `--bind` | `127.0.0.1` | Bind address. Use only a specific VPN/Tailscale interface IP for direct-VPN access; `0.0.0.0` / `::` are refused. |
| `--port` | `8765` | TCP port on the selected bind address |
| `--idle` | `30` | Per-session idle minutes (`0` = disable) |
| `--no-cache` | off | Skip OS keychain even if a DEK is cached. Forces every visit through the web passphrase form. |

Expand Down Expand Up @@ -114,8 +119,9 @@ Pros: Safari-only daily UX (one tap WG ON, one tap bookmark).
Cons: requires VPN setup + firewall hygiene. Misconfigured UFW = LAN exposure.

Required hygiene:
- Bind to the **VPN interface IP**, not `0.0.0.0`. The daemon literally
cannot accept connections from non-VPN paths if bound to `wg0`'s IP.
- Bind to the **VPN interface IP**, not a wildcard. `kpot serve`
refuses `--bind 0.0.0.0` and `--bind ::`; binding `wg0`'s IP prevents
non-VPN paths from reaching the daemon at the socket layer.
- UFW rule should restrict by interface (`in on wg0`) AND/OR by source
IP if multiple devices share the VPN.
- Confirm with `lsof -iTCP:8765 -sTCP:LISTEN` that the daemon binds the
Expand Down Expand Up @@ -225,7 +231,8 @@ extension" works on phones today.

When deploying, double-check:

- [ ] `lsof -iTCP:8765 -sTCP:LISTEN` shows `127.0.0.1:8765` (not `*:8765`).
- [ ] SSH-tunnel mode: `lsof -iTCP:8765 -sTCP:LISTEN` shows `127.0.0.1:8765` (not `*:8765`).
- [ ] Direct-VPN mode: `lsof -iTCP:8765 -sTCP:LISTEN` shows the exact VPN/Tailscale IP, not `*:8765`.
- [ ] Phone visits succeed only when SSH tunnel is active. Without
the tunnel, browser hits `localhost:8765` get connection refused.
- [ ] `kpot config show` confirms `keychain` is `auto` (or `always`)
Expand All @@ -239,6 +246,7 @@ When deploying, double-check:

- Auto-fill, multi-vault, RW from web — see the threat-model rationale
in the corresponding `docs/security.md` and the v0.9 plan file.
- `--bind 0.0.0.0` — refused on principle; the daemon is loopback-only.
- TLS — unnecessary on loopback; the SSH tunnel is the transport
encryption.
- `--bind 0.0.0.0` / `--bind ::` — refused on principle; bind loopback
or one specific trusted VPN/Tailscale interface IP.
- TLS — unnecessary on loopback/VPN; the SSH tunnel or VPN is the
transport encryption.
12 changes: 6 additions & 6 deletions internal/serve/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
// No Secure flag: kpot serve binds plain HTTP to 127.0.0.1
// only and the cookie's privacy hinges on the SSH tunnel
// boundary, not on TLS. Adding Secure here would prevent
// the cookie from sticking under plain HTTP (cookiejars
// follow RFC 6265 and refuse to persist Secure cookies
// over non-https), which would defeat the daemon entirely.
// No Secure flag: kpot serve is plain HTTP. The cookie's
// privacy hinges on the loopback / VPN / SSH-tunnel boundary,
// not on TLS. Adding Secure here would prevent the cookie from
// sticking under plain HTTP (cookiejars follow RFC 6265 and
// refuse to persist Secure cookies over non-https), which
// would defeat the daemon entirely.
})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
Expand Down
32 changes: 25 additions & 7 deletions internal/serve/serve.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Package serve hosts the kpot WebUI. One daemon per vault, bound to
// 127.0.0.1, accessed from a phone via SSH tunnel + VPN.
// Package serve hosts the kpot WebUI. One daemon per vault. It binds
// 127.0.0.1 by default for SSH-tunnel access, or a specific VPN
// interface IP when explicitly requested.
//
// Architecture and threat model are documented in docs/serve.md and
// /home/shin/.claude/plans/kpot-webui-url-id-ssh-vpn-vpn-fw0-ssh-we-distributed-charm.md.
Expand Down Expand Up @@ -116,6 +117,9 @@ func Run(opts Options) error {
if host == "" {
host = "127.0.0.1"
}
if isWildcard(host) {
return fmt.Errorf("serve: refusing wildcard bind %q; use 127.0.0.1 or a specific VPN/Tailscale interface IP", host)
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
ln, err := net.Listen("tcp", addr)
if err != nil {
Expand All @@ -138,11 +142,13 @@ func Run(opts Options) error {
}

fmt.Fprintf(os.Stderr,
"kpot serve: %s — http://localhost:%d/ (Ctrl-C to stop)\n",
opts.VaultPath, port)
fmt.Fprintf(os.Stderr,
" SSH tunnel from your phone:\n ssh -L %d:127.0.0.1:%d user@<this host>\n",
port, port)
"kpot serve: %s — http://%s/ (Ctrl-C to stop)\n",
opts.VaultPath, net.JoinHostPort(displayHost(host), fmt.Sprintf("%d", port)))
if isLoopback(host) {
fmt.Fprintf(os.Stderr,
" SSH tunnel from your phone:\n ssh -L %d:127.0.0.1:%d user@<this host>\n",
port, port)
}

// Catch SIGINT/SIGTERM for graceful shutdown.
idleConnsClosed := make(chan struct{})
Expand Down Expand Up @@ -185,6 +191,18 @@ func isLoopback(host string) bool {
return false
}

func isWildcard(host string) bool {
ip := net.ParseIP(host)
return ip != nil && ip.IsUnspecified()
}

func displayHost(host string) string {
if host == "" {
return "127.0.0.1"
}
return host
}

// mux builds the http.ServeMux. Exposed as a method so tests can wrap
// it in httptest.NewServer without spinning up the real listener.
func (s *Server) mux() *http.ServeMux {
Expand Down
46 changes: 46 additions & 0 deletions internal/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package serve
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
Expand Down Expand Up @@ -451,3 +452,48 @@ func TestBootstrapAutoMintsSessionForCookielessRequests(t *testing.T) {
t.Errorf("bootstrap should mint a cookie; got none")
}
}

func TestBindAddressClassification(t *testing.T) {
tests := []struct {
host string
loopback bool
wildcard bool
}{
{"", true, false},
{"localhost", true, false},
{"127.0.0.1", true, false},
{"::1", true, false},
{"10.0.0.1", false, false},
{"100.64.0.1", false, false},
{"0.0.0.0", false, true},
{"::", false, true},
{"vpn.example", false, false},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := isLoopback(tt.host); got != tt.loopback {
t.Errorf("isLoopback(%q)=%v want %v", tt.host, got, tt.loopback)
}
if got := isWildcard(tt.host); got != tt.wildcard {
t.Errorf("isWildcard(%q)=%v want %v", tt.host, got, tt.wildcard)
}
})
}
}

func TestRunRejectsWildcardBind(t *testing.T) {
err := Run(Options{
VaultPath: "unused.kpot",
BindAddr: "0.0.0.0",
NoCache: true,
})
if err == nil {
t.Fatal("expected wildcard bind to be rejected")
}
if !strings.Contains(err.Error(), "refusing wildcard bind") {
t.Fatalf("unexpected error: %v", err)
}
if errors.Is(err, http.ErrServerClosed) {
t.Fatalf("Run should reject before starting server: %v", err)
}
}
Loading