Files
fn_registry/dev/issues/0134-mesh-protocol-spec.md
T
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

980 lines
43 KiB
Markdown

---
id: "0134"
title: "Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain"
status: pending
type: spec
domain:
- infra
- cybersecurity
- protocols
scope: cross-app
priority: high
depends: []
blocks:
- "0135"
- "0136"
- "0137"
- "0138"
- "0139"
- "0140"
- "0141"
- "0142"
- "0143"
related:
- "0069"
related_flows:
- "0009"
created: 2026-05-24
updated: 2026-05-24
tags: [mesh, wireguard, matrix, e2ee, ed25519, manifest, audit-chain, security, spec, agents, devices]
flow: "0009"
dependencies: []
---
# 0134 — Mesh protocol spec
**Status:** pending
## Por que
Flow 0009 (`agentes-dispositivos-mesh`) introduce un bus de control multi-device sobre WireGuard + Matrix donde cada dispositivo (PC, movil, raspberry, container Docker) ejecuta capabilities firmadas por el operador. Sin un protocolo formal compartido, cada implementacion (device_agent en Go, bot dispatcher en agents_and_robots, panel Mesh en agents_dashboard, hub en wg_hub) va a derivar.
Este issue cierra Fase B del flow: define el contrato exacto que **toda** implementacion debe respetar — wire format, firmas, replay protection, approval flow, audit chain, error model, threat model. Las issues 0135-0143 implementan lo que aqui se define.
Una vez aceptado este spec, ningun cambio en el wire format se acepta sin un nuevo issue + bump de `protocol_version`.
## Anti-scope
- NO define como provision el WG hub (ver 0136).
- NO define UI del panel Mesh (ver 0138).
- NO define implementacion concreta del bot Matrix (ver 0142).
- NO entra en como se persiste `pass operator/ed25519` mas alla de su uso.
- NO define schema de la `operations.db` de cada app — solo el subset estrictamente compartido (`audit_log`, `room_devices`, `seen_nonces`).
## Conventions
- `protocol_version` (string): **"mesh/1"** — incluido en todo envelope.
- Todo timestamp es Unix epoch **segundos** (`int64`).
- Todo `*_id` es `[a-z0-9_-]+` lowercase, 4-64 chars.
- Todo nonce es 16 bytes random (`crypto/rand`), serializado como **base64url sin padding**.
- Todo hash es SHA-256, serializado como **hex lowercase** (64 chars).
- Toda firma ed25519 es 64 bytes, serializada como **base64url sin padding** (86 chars).
- Toda clave publica ed25519 es 32 bytes, serializada como **base64url sin padding** (43 chars).
- Fingerprint de clave publica = primeros 16 bytes hex de `SHA-256(pubkey_raw_32_bytes)`.
- JSON canonical: claves ordenadas alfabeticamente, sin espacios, UTF-8, sin BOM. Para firmas siempre usar la forma canonica.
---
## 1. JSON envelope
Toda invocacion de capability viaja como par request/response, ya sea sobre Matrix (eventos `m.room.message` con `msgtype = m.capability.*`) o sobre HTTP dentro del mesh WG (`POST /capability`).
### 1.1 Request
```json
{
"protocol_version": "mesh/1",
"request_id": "req_01J9XYZABCDEF",
"manifest_id": "manifest_home-wsl_v3",
"capability": "fs.read",
"args": {
"path": "/var/log/syslog",
"max_bytes": 4096
},
"ts": 1748131200,
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA",
"signature": "u2vh...QkA"
}
```
- `request_id`: ULID generado por el caller (agents_and_robots o el operador). Idempotency key — si la misma request llega 2x, device_agent debe devolver el mismo response sin re-ejecutar.
- `manifest_id`: id del capability manifest contra el cual se evalua. El device debe tener este manifest activo o rechazar `manifest_invalid`.
- `capability`: dotted name, ej. `shell.exec`, `fs.read`, `docker.container.list`. Debe estar en `manifest.capabilities[].name`.
- `args`: objeto JSON especifico de la capability. Schema validado por device_agent contra el manifest.
- `ts`: Unix seconds. Edad maxima 60s (ver §5).
- `nonce`: 16 bytes random, base64url. Unico por request (ver §5).
- `signature`: ed25519 sobre canonical bytes (ver 1.3).
### 1.2 Response
```json
{
"protocol_version": "mesh/1",
"request_id": "req_01J9XYZABCDEF",
"ok": true,
"result": {
"stdout": "May 24 12:00:00 localhost systemd[1]: Started session-1.scope.\n",
"stderr": "",
"exit_code": 0,
"truncated": false
},
"error": null,
"duration_ms": 42,
"audit_hash": "a3f5...09bc"
}
```
- `ok`: boolean. Si false, `result` ausente y `error` poblado.
- `error`: objeto `{code, message, details?}` cuando `ok=false`. `code` debe estar en §10.
- `duration_ms`: tiempo de ejecucion en device_agent (no incluye latencia Matrix).
- `audit_hash`: `this_hash` del registro en `audit_log` (ver §7). Permite al caller verificar la cadena.
Response NO va firmado por defecto — viaja sobre canal autenticado (Matrix E2EE o WG mesh). Si en el futuro se requiere firma de response (audit externo), se anade campo opcional `response_signature` con el mismo esquema canonical.
### 1.3 Canonical bytes para firma del request
```
canonical = "mesh/1\n" +
request_id + "\n" +
manifest_id + "\n" +
capability + "\n" +
sha256_hex(json_canonical(args)) + "\n" +
int_to_string(ts) + "\n" +
nonce
```
Bytes UTF-8, separador `\n` (0x0A). No trailing newline. Hash del args para no exponer args grandes a la firma (la firma se valida contra el hash, y `args` se reentrega tal cual; cualquier modificacion rompe la firma).
`json_canonical(args)`:
1. Si `args` es null o ausente, `json_canonical = "null"`.
2. Si `args` es objeto, recursivo: ordenar claves alfabeticamente, valores serializados sin espacios, strings con escape JSON estandar.
3. Si `args` es array, recursivo sobre cada elemento, sin reordenar.
### 1.4 Transport binding
| Transport | Request encoding | Response encoding |
|---|---|---|
| Matrix room event | `content.body` = JSON string, `msgtype` = `m.capability.request` | `m.capability.response` event en el mismo room |
| HTTP intra-mesh (`https://10.42.0.10:7777/capability`) | `POST` body JSON | response body JSON |
| SSE (streaming logs, `docker.logs --follow`) | request via POST | response inicial JSON `{ok, result: {stream_id}}` + SSE `event: chunk` |
Matrix es default. HTTP solo lo activa el operador con `mesh_http=true` en el manifest para casos de baja latencia (`docker.logs` tail interactivo, transferencias >1MB que Matrix limita).
---
## 2. Capability manifest
Documento firmado por el operador que autoriza a un device a ejecutar un set acotado de capabilities. Sin manifest valido, device_agent rechaza todo.
### 2.1 Schema YAML (legible) — fuente de verdad
```yaml
# manifest_home-wsl_v3.yaml
protocol_version: mesh/1
manifest_id: manifest_home-wsl_v3
device_id: home-wsl
operator: egutierrez@aurgi.com
operator_pubkey_fingerprint: "a1b2c3d4e5f60718"
issued_at: 1748131200
expires_at: 1779667200 # 1 year later
capabilities:
- name: shell.exec
requires_approval: false
constraints:
binaries_whitelist: [ls, cat, head, tail, grep, ps, df, du, uname, uptime]
max_duration_s: 10
max_output_bytes: 65536
cwd_allowed: ["/home/lucas", "/tmp"]
- name: fs.read
requires_approval: false
constraints:
paths_allowed: ["/home/lucas/**", "/var/log/syslog", "/etc/os-release"]
paths_denied: ["/home/lucas/.ssh/**", "/home/lucas/.password-store/**"]
max_bytes: 1048576
- name: fs.write
requires_approval: true
constraints:
paths_allowed: ["/home/lucas/inbox/**"]
max_bytes: 1048576
- name: docker.container.list
requires_approval: false
- name: docker.container.exec
requires_approval: true
constraints:
containers_allowed: ["agents_and_robots", "registry_api"]
binaries_whitelist: [ls, cat, ps]
max_duration_s: 30
```
### 2.2 JSON canonical (lo que se firma)
```json
{
"capabilities": [
{"constraints": {"binaries_whitelist": ["ls","cat","head","tail","grep","ps","df","du","uname","uptime"], "cwd_allowed":["/home/lucas","/tmp"], "max_duration_s": 10, "max_output_bytes": 65536}, "name": "shell.exec", "requires_approval": false},
{"constraints": {"max_bytes": 1048576, "paths_allowed": ["/home/lucas/**","/var/log/syslog","/etc/os-release"], "paths_denied": ["/home/lucas/.ssh/**","/home/lucas/.password-store/**"]}, "name": "fs.read", "requires_approval": false},
{"constraints": {"max_bytes": 1048576, "paths_allowed":["/home/lucas/inbox/**"]}, "name": "fs.write", "requires_approval": true},
{"name": "docker.container.list", "requires_approval": false},
{"constraints": {"binaries_whitelist": ["ls","cat","ps"], "containers_allowed": ["agents_and_robots","registry_api"], "max_duration_s": 30}, "name": "docker.container.exec", "requires_approval": true}
],
"device_id": "home-wsl",
"expires_at": 1779667200,
"issued_at": 1748131200,
"manifest_id": "manifest_home-wsl_v3",
"operator": "egutierrez@aurgi.com",
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
"protocol_version": "mesh/1"
}
```
Producido por `capability_manifest_canonicalize_go_infra` (function 0135 entrega).
### 2.3 Canonical bytes para firma del manifest
```
manifest_canonical = "mesh/1/manifest\n" + json_canonical(manifest_without_signature)
```
Donde `manifest_without_signature` es el JSON canonical de §2.2. El prefijo `mesh/1/manifest\n` es domain separator — evita que una firma de manifest pueda interpretarse como firma de envelope.
### 2.4 Manifest signed envelope (lo que se entrega al device)
```json
{
"manifest": { /* §2.2 */ },
"signature": "k7Yp...QwE"
}
```
Persistido en device como `~/.config/device_agent/manifests/manifest_home-wsl_v3.json`.
### 2.5 Reglas de verificacion (device_agent al arrancar y al recibir request)
1. Parsear `manifest` y `signature`.
2. Computar `manifest_canonical`.
3. Verificar `ed25519.Verify(operator_pubkey, manifest_canonical, signature)`.
4. Rechazar si `expires_at < now()``manifest_invalid` con `details.reason = "expired"`.
5. Rechazar si `issued_at > now() + 300` (clock skew) → `manifest_invalid` con `details.reason = "future_issued"`.
6. Rechazar si `device_id` no coincide con `~/.config/device_agent/device_id``manifest_invalid`.
7. Rechazar si `operator_pubkey_fingerprint` no coincide con la pubkey conocida → `manifest_invalid`.
### 2.6 Rotacion
Un manifest nuevo coexiste con el anterior hasta su `expires_at`. Para forzar revocacion inmediata: el hub publica un evento `manifest_revoked` (en room `#operator-broadcast`) firmado por el operador con la lista de `manifest_id` revocados. device_agent mantiene `~/.config/device_agent/revoked_manifests.json` y lo consulta antes de aceptar.
---
## 3. ed25519 signing flow
### 3.1 Keypair
- **Privada** del operador: `pass operator/ed25519`. Linea 1 = base64url de los 32 bytes del seed ed25519. Lineas siguientes = metadata (operador email, created_at, fingerprint).
- **Publica** del operador: `~/.fn_operator.pub`. Contenido = base64url de los 32 bytes raw + `\n`. Distribuida a cada device en su `~/.config/device_agent/operator.pub` durante enrollment.
### 3.2 Generacion (una sola vez en la vida del operador, idempotente)
```bash
# Funcion del registry: operator_keygen_bash_infra (0135 entrega)
operator_keygen() {
if pass show operator/ed25519 >/dev/null 2>&1; then
echo "operator key already exists; skipping"
return 0
fi
local seed pub fp
seed=$(openssl rand 32 | base64 -w0 | tr '+/' '-_' | tr -d '=')
pub=$(echo "$seed" | go_ed25519_derive_pub) # helper Go: seed → pub
fp=$(echo -n "$(echo "$pub" | base64url -d)" | sha256sum | cut -c1-32)
pass insert -m operator/ed25519 <<EOF
$seed
operator: $(git config user.email)
created_at: $(date +%s)
fingerprint: $fp
pubkey: $pub
EOF
echo "$pub" > ~/.fn_operator.pub
chmod 600 ~/.fn_operator.pub
}
```
### 3.3 Sign
```go
// capability_manifest_sign_go_infra (issue 0135)
func SignManifest(m Manifest, seed []byte) ([]byte, error) {
if len(seed) != ed25519.SeedSize {
return nil, ErrInvalidSeed
}
canonical, err := canonicalizeManifest(m)
if err != nil { return nil, err }
msg := append([]byte("mesh/1/manifest\n"), canonical...)
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, msg), nil
}
```
Para envelopes:
```go
func SignRequest(r Request, seed []byte) ([]byte, error) {
canonical, err := canonicalRequestBytes(r)
if err != nil { return nil, err }
priv := ed25519.NewKeyFromSeed(seed)
return ed25519.Sign(priv, canonical), nil
}
```
`canonicalRequestBytes` implementa §1.3.
### 3.4 Verify (device_agent)
```go
// capability_manifest_verify_go_infra (issue 0135)
func VerifyManifest(signed SignedManifest, pubkey []byte) error {
if len(pubkey) != ed25519.PublicKeySize {
return ErrInvalidPubkey
}
canonical, err := canonicalizeManifest(signed.Manifest)
if err != nil { return err }
msg := append([]byte("mesh/1/manifest\n"), canonical...)
if !ed25519.Verify(pubkey, msg, signed.Signature) {
return ErrSignatureInvalid
}
return nil
}
```
### 3.5 Domain separators (criticos)
Cada tipo de firma usa prefix unico para evitar cross-protocol attacks:
| Tipo | Prefix |
|---|---|
| Manifest | `"mesh/1/manifest\n"` |
| Request envelope | `"mesh/1/request\n"` (implicito en §1.3 — usa el `\n` join) |
| Enrollment token | `"mesh/1/enroll\n"` |
| Approval token | `"mesh/1/approval\n"` |
| Manifest revocation | `"mesh/1/revoke\n"` |
---
## 4. Enrollment token
Token corto firmado por el operador que un device usa **una sola vez** para registrarse contra `wg_hub` y obtener su config WireGuard + manifest inicial.
### 4.1 Payload JSON (canonical)
```json
{
"allowed_capabilities": ["shell.exec", "fs.read", "docker.container.list"],
"device_id": "home-wsl",
"expires_at": 1748131800,
"issued_at": 1748131200,
"nonce": "Tk9NbjVxV3JLcF9j",
"operator_pubkey_fingerprint": "a1b2c3d4e5f60718",
"protocol_version": "mesh/1",
"purpose": "enroll"
}
```
- `expires_at - issued_at` <= 600s (10 min). Hub rechaza si excede.
- `allowed_capabilities`: subset que el operador autoriza a este enrollment. El manifest final puede ser mas restrictivo pero no mas amplio.
- `nonce` previene replay incluso si el operador reusa el token por error.
- `purpose: "enroll"` es discriminator interno; los otros valores reservados son `"approval"` (§6) y `"revoke"`.
### 4.2 Wire format
```
base64url(json_canonical(payload)) + "." + base64url(ed25519_signature)
```
Ejemplo (truncado):
```
eyJhbGxvd2VkX2NhcGFiaWxpdGllcyI6WyJzaGVsbC5leGVjIiwiZnMucmVhZCJdLCJkZXZpY2VfaWQiOiJob21lLXdzbCIsLi4u.k7YpQwE9Vh...
```
Aproximadamente 280-320 bytes. Cabe en un QR Code v6 (error correction M).
### 4.3 Generacion (operador)
```bash
# enroll_device pipeline (issue 0139) llama:
./fn run enrollment_token_create home-wsl \
--capabilities "shell.exec,fs.read,docker.container.list" \
--ttl 600
# stdout: token base64url
```
### 4.4 Verificacion (wg_hub `POST /enroll`)
```go
// enrollment_token_verify_go_infra (issue 0135)
func VerifyEnrollToken(raw string, pubkey []byte) (*EnrollPayload, error) {
parts := strings.Split(raw, ".")
if len(parts) != 2 { return nil, ErrTokenMalformed }
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil { return nil, ErrTokenMalformed }
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil { return nil, ErrTokenMalformed }
msg := append([]byte("mesh/1/enroll\n"), payloadBytes...)
if !ed25519.Verify(pubkey, msg, sig) {
return nil, ErrSignatureInvalid
}
var p EnrollPayload
if err := json.Unmarshal(payloadBytes, &p); err != nil {
return nil, ErrTokenMalformed
}
now := time.Now().Unix()
if p.ExpiresAt < now { return nil, ErrTokenExpired }
if p.IssuedAt > now + 300 { return nil, ErrTokenFutureIssued }
if p.Purpose != "enroll" { return nil, ErrTokenWrongPurpose }
return &p, nil
}
```
### 4.5 POST /enroll (wg_hub)
```
POST https://organic-machine.com/enroll
Content-Type: application/json
{
"enrollment_token": "eyJhbGxv...QwE",
"device_pubkey_wg": "K3v8...j0c=", // WG public key del device, generada por wg_keygen
"device_hostname": "home-wsl",
"device_os": "linux-wsl2",
"device_arch": "x86_64"
}
```
Response:
```json
{
"ok": true,
"wg_config": "[Interface]\nPrivateKey = <keep on device>\nAddress = 10.42.0.10/24\n[Peer]\nPublicKey = ...\nEndpoint = organic-machine.com:51820\nAllowedIPs = 10.42.0.0/24\nPersistentKeepalive = 25\n",
"manifest": { /* signed manifest */ },
"matrix_room": "!abc123:organic-machine.com",
"matrix_invite_url": "https://matrix.to/#/!abc123:organic-machine.com?via=organic-machine.com"
}
```
Hub marca el token como `consumed` en `wg_enrollment_tokens` (token_nonce as PK) — segundo uso rechazado con `nonce_replay`.
---
## 5. Replay protection
### 5.1 Nonces
- 16 bytes `crypto/rand` por request.
- Server (device_agent O hub) mantiene tabla `seen_nonces`:
```sql
CREATE TABLE IF NOT EXISTS seen_nonces (
nonce TEXT PRIMARY KEY, -- base64url
seen_at INTEGER NOT NULL, -- unix seconds
request_id TEXT NOT NULL,
expires_at INTEGER NOT NULL -- seen_at + 300
);
CREATE INDEX IF NOT EXISTS idx_seen_nonces_expires ON seen_nonces(expires_at);
```
- TTL = 300s. Job periodico (cada 60s) borra entradas con `expires_at < now()`.
### 5.2 Timestamp
- `ts` debe estar en `[now-60, now+30]`. Mas viejo → `nonce_replay`. Mas futuro → `signature_invalid` con `details.reason="clock_skew"`.
- Asume devices con NTP sync (`chrony`/`systemd-timesyncd`). Si un device tiene clock drift >30s, el operador recibe alerta en `#operator-approvals`.
### 5.3 Algoritmo
```go
// Pseudo
func AcceptNonce(db *sql.DB, nonce string, ts int64, requestID string) error {
now := time.Now().Unix()
if ts < now - 60 { return ErrNonceReplay }
if ts > now + 30 { return ErrSignatureInvalid /* clock_skew */ }
_, err := db.Exec(
`INSERT INTO seen_nonces(nonce, seen_at, request_id, expires_at) VALUES(?,?,?,?)`,
nonce, now, requestID, now+300,
)
if isUniqueViolation(err) { return ErrNonceReplay }
return err
}
```
`AcceptNonce` se llama **antes** de ejecutar la capability, despues de verificar la firma. Si la firma es invalida pero el nonce es nuevo, NO se inserta (evita amplificar log spam).
---
## 6. Approval flow
Para capabilities con `requires_approval: true`, device_agent NO ejecuta sin recibir un approval token firmado por el operador.
### 6.1 Secuencia
```
[operator] [agents_and_robots] [device_agent] [#operator-approvals]
| | | |
| !exec rm -rf /tmp/x in #dev-home-wsl | |
|------------------->| | |
| |--- request envelope ->| |
| | |--- decide: approval needed
| | | |
| |<--- approval_request -| |
| |--- post to #op-approvals ----------------->|
| | | |
|<--- notification --| | |
| |
|--- reacts 👍 OR posts !approve req_01J9... ------------------->|
| |
| |<--- captures reaction/cmd -----------------|
| |--- signs approval_token (via operator key)
| |--- posts approval_token to #dev-home-wsl
| | | |
| |--- approval_token --->| |
| | |--- verifies token |
| | |--- executes |
| |<--- response ---------| |
|<-- output in #dev-home-wsl |
```
### 6.2 Approval token payload
```json
{
"protocol_version": "mesh/1",
"purpose": "approval",
"request_id": "req_01J9XYZABCDEF",
"manifest_id": "manifest_home-wsl_v3",
"capability": "shell.exec",
"args_hash": "a3f5...09bc",
"approver": "egutierrez@aurgi.com",
"approved_at": 1748131245,
"expires_at": 1748131305,
"nonce": "Yk9p6Xs_3hZQk4mB7lWcvA"
}
```
`args_hash` debe coincidir con `sha256_hex(json_canonical(args))` del request original — evita que el operador apruebe `ls /tmp` y el bot reemplace por `rm -rf /tmp`.
### 6.3 Wire format
Igual que enrollment token: `base64url(payload) + "." + base64url(signature)`, domain separator `"mesh/1/approval\n"`.
### 6.4 Captura por agents_and_robots
`agents_and_robots` corre como bot Matrix con la operator key cargada (a traves de `pass operator/ed25519` montado en `/etc/agents_and_robots/operator.key` con permisos 400, owned by service user). Cuando detecta:
- Reaccion `m.reaction` con key `👍` (U+1F44D) sobre el evento `approval_request` en `#operator-approvals`, **emitida por el matrix_id del operador** (configurado en `apps/agents_and_robots/config.yaml::operator_matrix_id`).
- O mensaje `!approve <request_id>` en `#operator-approvals` desde el mismo matrix_id.
Entonces firma el approval token y lo envia a device_agent (via Matrix event `m.capability.approval` en el room del device).
### 6.5 Timeout
Si device_agent no recibe approval token en 60s tras enviar approval_request, responde al room con error `approval_timeout`. El operador puede re-emitir el comando original (genera nuevo `request_id`, nuevo nonce, nueva approval).
### 6.6 Approval denegada
Reaccion `👎` (U+1F44E) o comando `!deny <request_id>` → bot firma un `approval_denied_token` (mismo formato + `denied=true`). device_agent responde `approval_denied`.
### 6.7 Verificacion en device_agent
```go
// Pseudo
func VerifyApproval(token string, req Request, pubkey []byte) error {
payload, err := decodeApprovalToken(token, pubkey)
if err != nil { return err }
if payload.RequestID != req.RequestID { return ErrApprovalMismatch }
if payload.Capability != req.Capability { return ErrApprovalMismatch }
if payload.ArgsHash != sha256Hex(canonicalJSON(req.Args)) { return ErrApprovalMismatch }
if payload.ExpiresAt < time.Now().Unix() { return ErrApprovalExpired }
if payload.Denied { return ErrApprovalDenied }
return nil
}
```
---
## 7. Audit log hash chain
Append-only log local a cada device_agent con hash chain que detecta tampering. Replicado periodicamente al hub para archivo tamper-evident off-device.
### 7.1 Schema (`apps/device_agent/audit.db`)
```sql
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
request_id TEXT NOT NULL,
manifest_id TEXT NOT NULL,
capability TEXT NOT NULL,
args_hash TEXT NOT NULL,
approval_id TEXT, -- nullable, request_id del approval token usado
exit_code INTEGER, -- nullable mientras no haya respuesta
ok INTEGER NOT NULL, -- 0/1
error_code TEXT, -- nullable
duration_ms INTEGER NOT NULL,
prev_hash TEXT NOT NULL, -- hex 64 chars
this_hash TEXT NOT NULL, -- hex 64 chars
UNIQUE(request_id)
);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
```
Migracion vive en `apps/device_agent/migrations/001_init.sql`. Regla `db_migrations.md`.
### 7.2 Hash chain
```
record_canonical = ts + "|" + request_id + "|" + manifest_id + "|" +
capability + "|" + args_hash + "|" + approval_id_or_empty + "|" +
exit_code_str + "|" + ok_str + "|" + error_code_or_empty + "|" +
duration_ms_str
this_hash = sha256_hex(prev_hash + "\n" + record_canonical)
```
Para el primer registro `prev_hash = "0000...0000"` (64 zeros).
`wg_peer_revoke_go_infra` (ya existente) hace algo similar para revocations; este spec usa el mismo patron para todas las invocaciones.
### 7.3 Append helper
```go
// device_audit_append_go_infra (issue 0135)
func AppendAudit(db *sql.DB, rec Record) (string, error) {
var prev string
err := db.QueryRow(`SELECT this_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prev)
if err == sql.ErrNoRows {
prev = strings.Repeat("0", 64)
} else if err != nil {
return "", err
}
canonical := canonicalRecord(rec)
h := sha256.Sum256([]byte(prev + "\n" + canonical))
this := hex.EncodeToString(h[:])
_, err = db.Exec(`INSERT INTO audit_log(...) VALUES(...)`, /* fields, prev, this */)
if err != nil { return "", err }
return this, nil
}
```
Transaccion con `BEGIN IMMEDIATE` para evitar carrera entre prev_hash select y insert.
### 7.4 Verificacion (cualquiera con copia del db)
```go
// device_audit_verify_go_infra (issue 0135)
func VerifyChain(db *sql.DB) error {
rows, _ := db.Query(`SELECT id, prev_hash, this_hash, /* fields */ FROM audit_log ORDER BY id`)
expected := strings.Repeat("0", 64)
for rows.Next() {
var rec Record
rows.Scan(&rec.ID, &rec.PrevHash, &rec.ThisHash /* ... */)
if rec.PrevHash != expected { return fmt.Errorf("chain broken at id %d", rec.ID) }
canonical := canonicalRecord(rec)
h := sha256.Sum256([]byte(rec.PrevHash + "\n" + canonical))
if hex.EncodeToString(h[:]) != rec.ThisHash {
return fmt.Errorf("hash mismatch at id %d", rec.ID)
}
expected = rec.ThisHash
}
return nil
}
```
### 7.5 Replicacion al hub
Cada 60s device_agent hace `POST /audit/replicate` al hub con el bloque de registros nuevos (delta sobre el ultimo replicado). El hub valida la cadena, anade su propio `replicated_at`, y almacena en `apps/wg_hub/operations.db::device_audit` (tabla espejo + meta `last_replicated_id` por device).
Si el hub detecta `chain_broken` o `hash_mismatch`, emite evento a `#operator-approvals` con severity=critical y marca device como `status='compromised'` en `wg_peers`.
---
## 8. Room ↔ device mapping
### 8.1 Schema (`apps/agents_and_robots/operations.db`)
```sql
CREATE TABLE IF NOT EXISTS room_devices (
room_id TEXT PRIMARY KEY, -- !abc123:organic-machine.com
device_id TEXT NOT NULL,
manifest_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'device' | 'container' | 'approval' | 'broadcast'
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_room_devices_device_id ON room_devices(device_id);
CREATE INDEX IF NOT EXISTS idx_room_devices_role ON room_devices(role);
```
Migracion: `apps/agents_and_robots/migrations/NNN_room_devices.sql`.
### 8.2 Roles especiales
- `role='approval'`: hay exactamente UN room con este rol, default alias `#operator-approvals:organic-machine.com`. Bot publica `approval_request` aqui y escucha reacciones del operador.
- `role='broadcast'`: alias `#operator-broadcast:organic-machine.com`. Bot publica eventos de control firmados (revocations, manifest rotations).
- `role='device'`: room 1:1 por device. Alias por convencion `#dev-<device_id>:organic-machine.com`.
- `role='container'`: para modo "deep" docker (containers como peers WG). Alias `#cont-<container_id>:organic-machine.com`.
### 8.3 Resolucion al dispatchar
Cuando el bot recibe un `!cmd` en cualquier room:
1. Busca `SELECT device_id, manifest_id, role FROM room_devices WHERE room_id=? AND active=1`.
2. Si no existe → ignora (o responde "this room is not bound to a device").
3. Si `role='device'`, dispatcha al `device_agent` correspondiente.
4. Si `role='approval'` o `role='broadcast'`, NO acepta `!exec`/`!fs.*`/`!docker.*` — solo `!approve`, `!deny`, `!revoke`, `!help`.
---
## 9. Element commands
Comandos que el bot de `agents_and_robots` parsea y traduce a envelopes. Estos viven en rooms `role='device'` o `role='container'` (excepto `!approve`/`!deny` que viven en `role='approval'`).
| Command | Capability | Args | Notas |
|---|---|---|---|
| `!help` | (meta) | none | Bot responde con capability matrix del manifest del device del room |
| `!exec <argv...>` | `shell.exec` | `{argv: [...], cwd?: string}` | argv splits por shlex, sin shell wrapping |
| `!fs.read <path> [bytes]` | `fs.read` | `{path, max_bytes?}` | default max_bytes = manifest.max_bytes |
| `!fs.write <path> <<<content` | `fs.write` | `{path, content_base64}` | content viene en heredoc o quoted; bot codifica base64 |
| `!fs.ls <path>` | `fs.list` | `{path}` | output: array de {name,type,size} |
| `!docker exec <container> <argv...>` | `docker.container.exec` | `{container, argv}` | container debe estar en `containers_allowed` |
| `!docker logs <container> [tail]` | `docker.container.logs` | `{container, tail?, follow?}` | `--follow` activa SSE |
| `!docker ps` | `docker.container.list` | `{}` | output: tabla containers vivos |
| `!approve <req_id>` | (meta) | `{request_id}` | solo en `role='approval'`, solo del operator_matrix_id |
| `!deny <req_id>` | (meta) | `{request_id, reason?}` | idem |
| `!revoke <device_id>` | (meta) | `{device_id, reason?}` | solo del operator_matrix_id; emite `manifest_revoked` + `wg_peer_revoke` |
| `!status` | (meta) | none | bot responde: device IP WG, last_handshake, manifest_id activo, capabilities count |
### 9.1 Parsing rules
- Lexer shlex-style (`shlex.split` Python o equivalente). Quoted strings respetan espacios.
- Si parsing falla → `!help` corto en la misma linea + abort.
- Args desconocidos para una capability → `manifest_invalid` con `details.unknown_args: [...]`.
### 9.2 Output rendering
El bot formatea responses para Matrix:
- `ok=true`, output corto (<2KB): formatted text con `<pre><code>...</code></pre>` (Matrix `formatted_body`).
- `ok=true`, output largo: trim a 2KB + link al artifact subido (Matrix media repo si el homeserver lo permite, sino paste a `paste.organic-machine.com`).
- `ok=false`: render como `[ERROR error_code] message\n<details>` con codigo de color rojo en clientes que soportan colored text.
---
## 10. Error model
Todos los `error.code` son strings snake_case. El cliente NO debe parsear `message` — solo `code`. `details` es objeto libre por code.
| code | meaning | details fields | retry? |
|---|---|---|---|
| `manifest_invalid` | Manifest no firma, expirado, device_id mismatch, o no tiene la capability | `reason`, `manifest_id`, `expires_at?` | no — pedir manifest nuevo |
| `capability_denied` | Capability esta en manifest pero los args violan constraints | `constraint_violated`, `value` | no — ajustar args |
| `binary_not_whitelisted` | shell.exec con binario fuera de `binaries_whitelist` | `binary`, `whitelist` | no |
| `path_not_allowed` | fs.* con path fuera de `paths_allowed` o en `paths_denied` | `path`, `allowed_globs`, `denied_globs` | no |
| `container_not_allowed` | docker.* sobre container fuera de `containers_allowed` | `container`, `allowed_list` | no |
| `approval_timeout` | requires_approval=true y no llego token en 60s | `waited_s` | si — re-enviar |
| `approval_denied` | operador denego | `approver`, `reason?` | no |
| `approval_mismatch` | approval token args_hash != request args_hash | `expected_hash`, `got_hash` | no — posible MITM |
| `nonce_replay` | nonce ya visto en ventana TTL=300s | `nonce`, `first_seen_at` | no — generar nonce nuevo |
| `signature_invalid` | firma ed25519 no verifica | `reason` (e.g. `clock_skew`, `bad_pubkey`, `corrupted`) | no |
| `token_expired` | enrollment o approval token expirado | `expires_at`, `now` | no |
| `token_consumed` | enrollment token ya usado (nonce en `wg_enrollment_tokens`) | `first_use_at` | no |
| `device_revoked` | device esta en revocation list | `revoked_at`, `reason` | no |
| `capability_not_found` | capability name no existe en device_agent | `name`, `available` | no |
| `execution_failed` | la capability ejecuto y devolvio exit != 0 | `exit_code`, `stderr` (trimmed) | depende — semantica de la capability |
| `output_too_large` | output > `max_output_bytes` | `bytes`, `limit` | no — pedir con tail/head |
| `duration_exceeded` | timeout `max_duration_s` excedido | `limit_s`, `killed_signal` | no |
| `transport_error` | error de Matrix/HTTP transport debajo del envelope | `transport`, `inner_error` | si con backoff |
| `internal` | bug en device_agent/hub/bot; NO leakear stack al room | `incident_id` | no — operator debe ver logs |
### 10.1 Mapping a HTTP status (transport HTTP intra-mesh)
| code | HTTP |
|---|---|
| `manifest_invalid`, `signature_invalid`, `token_*` | 401 |
| `capability_denied`, `binary_not_whitelisted`, `path_not_allowed`, `container_not_allowed`, `device_revoked` | 403 |
| `capability_not_found` | 404 |
| `nonce_replay`, `approval_mismatch` | 409 |
| `approval_timeout`, `duration_exceeded` | 408 |
| `output_too_large` | 413 |
| `approval_denied` | 403 |
| `execution_failed` | 200 (con ok=false, exit_code en body) |
| `transport_error`, `internal` | 500 |
Matrix transport ignora HTTP status — el `ok=false` y `error.code` son suficientes.
---
## 11. Security threat model
Top 10 ataques + mitigaciones. Listadas por probabilidad x impacto. Cada item refiere al control que lo mitiga.
### T1. Operator ed25519 key leak
- **Attack**: laptop comprometida, `pass operator/ed25519` exfiltrado.
- **Impact**: atacante firma manifests + approvals — control total de devices.
- **Mitigation**:
- GPG-encrypted at rest via `pass` (depende de GPG subkey).
- Rotacion forzada: `!revoke-all` en `#operator-broadcast` → todos los devices reciben `manifest_revoked` con `device_id="*"` → entran en modo enroll.
- Hardware-backed key (YubiKey, ssh-agent con touch policy) — out of scope v1, candidato a issue futuro.
- Detection: hub registra todas las firmas (mensaje `signed_by_operator_at`); operador revisa diariamente.
### T2. Compromised device executes unauthorized capabilities
- **Attack**: device_agent comprometido, atacante quiere ejecutar capabilities fuera del manifest.
- **Impact**: limitado a las capabilities del manifest (asumiendo verify es correcto).
- **Mitigation**:
- Manifest verify obligatorio antes de cada request (§2.5).
- Sandbox: `firejail` (Linux) o equivalente — ver issue 0140.
- Whitelist binarios + paths + containers (§2).
- Audit chain replicado a hub (§7.5) — atacante no puede borrar audit.
### T3. Replay attack
- **Attack**: atacante captura request firmado valido (de un log, de Matrix federation leak), lo reenvia.
- **Impact**: capability ejecutada 2x.
- **Mitigation**: §5. Nonce TTL=300s + ts window 60s. SQLite UNIQUE constraint.
### T4. MITM despite WireGuard
- **Attack**: alguien dentro del mesh WG (otro device comprometido) intercepta requests entre bot y device.
- **Impact**: leer args/output; modificar args si firma se ignora.
- **Mitigation**:
- HTTP intra-mesh sobre TLS (cert auto-firmado mesh-only, pinned).
- Matrix transport: E2EE via Olm/Megolm — bot debe verificar device keys del room antes de aceptar.
- Firma del envelope (§1.3) — args modificados → `signature_invalid`.
### T5. Container escape
- **Attack**: container con `docker.container.exec` activado escapa a host.
- **Impact**: host comprometido, no mas que T2.
- **Mitigation**:
- `binaries_whitelist` estricta en `docker.container.exec` (sin `bash`, `sh`, `nsenter`, `unshare`).
- Modo "deep" (container con WG-peer propio) solo para containers de propia infra (`agents_and_robots`, `registry_api`).
- Docker socket NUNCA expuesto via capability (capability solo via `docker_container_exec_go_infra` que NO usa `--privileged` en exec).
- Detection: container con syscalls anomalas → logged por seccomp profile (out of scope v1).
### T6. Malicious manifest with crafted globs
- **Attack**: operador firma manifest con `paths_allowed: ["/home/lucas/**"]` pero device_agent tiene bug en glob matcher que permite `..` traversal.
- **Impact**: fs.read fuera del directorio.
- **Mitigation**:
- Implementacion glob: `filepath.Match` Go + canonicalizar path con `filepath.Clean` + verificar `strings.HasPrefix(cleaned, allowed_prefix)`.
- Reject paths con `..` antes de glob match.
- Test suite con corpus de path traversal (`../../etc/passwd`, `/home/lucas/../etc/passwd`, symlinks).
### T7. Enrollment token theft
- **Attack**: token QR fotografiado por tercero.
- **Impact**: tercero hace POST /enroll con su propia WG pubkey → device fantasma en la mesh.
- **Mitigation**:
- TTL=600s.
- Single-use (nonce consumed en hub).
- Operador recibe alerta en `#operator-approvals` cada vez que un device hace POST /enroll exitoso — si no esperabas un enroll, `!revoke` inmediato.
### T8. Matrix homeserver compromise
- **Attack**: atacante root en `organic-machine.com` modifica eventos Matrix.
- **Impact**: si E2EE roto, todo el contenido leak. Si E2EE OK, solo metadata.
- **Mitigation**:
- Megolm E2EE entre operador y bot — keys nunca en disco del homeserver.
- Envelope firmado (§1.3) — atacante no puede inyectar requests sin operator key.
- Hub WG segregado: `wg_hub` corre en mismo VPS pero NO confia en Matrix para autorizacion (solo para transport).
### T9. Clock skew abuse
- **Attack**: device con clock muy adelantado firma requests con `ts` futuro grande, los almacena, los reenvia cuando `ts` cae en ventana.
- **Impact**: replay extendido mas alla de TTL.
- **Mitigation**:
- Ventana ts: `[now-60, now+30]` — clock skew tolerado pequeño.
- Devices forzados a sync NTP (chrony) — el provision check verifica `chronyc tracking` reporta `Leap status: Normal`.
- Hub alerta a `#operator-approvals` si recibe replicacion de audit con `ts` que difiere >30s del `received_at`.
### T10. Denial of service via approval flooding
- **Attack**: atacante con manifest valido pero capabilities limitadas spam `requires_approval=true` requests para inundar `#operator-approvals`.
- **Impact**: operador pierde signal en noise; legitimo approval enterrado.
- **Mitigation**:
- Rate limit en agents_and_robots: por device_id, max 10 approval_requests / 5min.
- Excedente → silently dropped + audit entry + `#operator-approvals` recibe resumen agregado (`device home-wsl: 47 approval requests in 5min, throttled`).
- Si pattern repetido, operador `!revoke home-wsl`.
---
## 12. Implementation order
Las issues 0135-0143 implementan lo definido en este spec. Dependencias y orden:
| # | Issue | Que entrega | Depende de | Bloquea |
|---|---|---|---|---|
| 0135 | capability manifest sign/verify funcs | `capability_manifest_sign_go_infra`, `capability_manifest_verify_go_infra`, `enrollment_token_create_go_infra`, `enrollment_token_verify_go_infra`, `device_audit_append_go_infra`, `device_audit_verify_go_infra`, `operator_keygen_bash_infra` | 0134 (spec) | 0136, 0137, 0140, 0142 |
| 0136 | provision_wg_hub pipeline | `provision_wg_hub_bash_pipelines` que compone las 9 funciones `wg_*` (ver flow 0009 Fase C) | 0135 (audit funcs solamente; resto independiente) | 0137 |
| 0137 | wg_hub Go service | `apps/wg_hub/` con endpoints `POST /enroll`, `GET /peers`, `POST /peers/:id/revoke`, `POST /audit/replicate`, SSE `/events` | 0135, 0136 | 0138, 0139 |
| 0138 | agents_dashboard Mesh panel | Panel ImGui en `apps/agents_dashboard/` con lista de peers, last_handshake, bytes rx/tx, approval queue, boton revoke | 0137 | — |
| 0139 | enroll_device pipeline | `enroll_device_bash_pipelines` que el operador corre en su laptop para enroll un device nuevo (genera token, lo muestra como QR, hace POST /enroll en nombre del device si tiene SSH) | 0135, 0137 | 0140, 0141 |
| 0140 | device_agent Go binary | `apps/device_agent/` — Matrix client + capability dispatcher + sandbox firejail + audit chain. Cross-compile linux/amd64, linux/arm64, windows/amd64 | 0135, 0139 | 0142 |
| 0141 | Android Termux variant | `apps/device_agent_android/` — variante para Termux con WG via wg-go (userspace) y capabilities limitadas (no firejail) | 0140 | — |
| 0142 | Matrix bot dispatcher routes | Extender `apps/agents_and_robots/` con dispatcher `m.capability.*` → device, parse de comandos §9, room_devices table | 0135, 0137, 0140 | 0143 |
| 0143 | Operator approval flow | Capturar reactions en `#operator-approvals`, firmar approval tokens, enviar a device, registrar timeout. En `apps/agents_and_robots/` | 0142 | — |
### 12.1 Paralelismo
- 0135 secuencial (todo lo demas depende).
- 0136 + 0140 paralelos tras 0135.
- 0137 espera 0136 (necesita las funciones `wg_*`).
- 0138 + 0139 + 0140 paralelos tras 0137.
- 0141 tras 0140.
- 0142 + 0143 ultimo bloque.
Skill `parallel-fix-issues` puede orquestar 0136/0140 y 0138/0139 en worktrees aislados (ojo: 0140 crea sub-repo `apps/device_agent/`, requiere `git init` dentro como dice `apps_subrepo.md`).
### 12.2 Acceptance gate para cerrar 0134
- [ ] Este documento mergeado en `dev/issues/`.
- [ ] Issues 0135-0143 creados con frontmatter coherente (`dependencies` apuntando aqui, `related_flows: [0009]`).
- [ ] Capabilities groups `wireguard`, `device-agent`, `docker-agent` con stubs en `docs/capabilities/` referenciando este spec.
- [ ] No changes en wire format hasta que todos los issues 0135-0143 cierren — cambios posteriores requieren nuevo issue + bump `protocol_version`.
---
## Notas
### Wire format evolution policy
`protocol_version: mesh/1` es immutable durante el ciclo de vida de las issues 0135-0143. Cualquier cambio breaking (renombrar campo, cambiar canonical bytes, anadir campo obligatorio) requiere bump a `mesh/2` con un issue nuevo que documente migracion y compat layer.
Cambios non-breaking aceptados sin bump:
- Anadir nuevos `error.code` (clientes los manejan via fallback a `internal`).
- Anadir nuevas capabilities (devices viejos las rechazan con `capability_not_found`).
- Anadir campos opcionales con default backwards-compatible.
### Test corpus
Cada funcion de §3 y §4 entrega test fixtures en `cpp/functions/*/testdata/` o equivalente:
- `manifest_valid.json` + `manifest_valid.sig` — par validable.
- `manifest_expired.json` — para test de §2.5 paso 4.
- `manifest_tampered.json` + sig original — para test de signature_invalid.
- `enroll_token_valid.txt`, `enroll_token_expired.txt`, `enroll_token_wrong_purpose.txt`.
- `path_traversal_corpus.txt` con 50+ paths maliciosos para test T6.
### Capability groups stubs
Como parte de este issue se crean stubs minimos en:
- `docs/capabilities/wireguard.md` — lista de las 9 funciones `wg_*` (referenciadas desde flow 0009).
- `docs/capabilities/device-agent.md` — capability dispatcher + sandbox + audit chain.
- `docs/capabilities/docker-agent.md` — capabilities sobre containers.
Cada uno con seccion `## Ejemplo canonico` + `## Fronteras` segun regla `capability_groups.md`. Los stubs se llenan a medida que las funciones se crean en 0135-0142.
### Observabilidad
- Cada envelope request/response loggea en `call_monitor.calls` con `function_id = capability_<name>_<lang>_<domain>` cuando la implementacion exista. Si la capability es solo metadata (`!help`), no se loggea.
- `audit_log` (§7) es separado de `call_monitor` — el primero es tamper-evident del operator, el segundo es telemetria del agente Claude.
- Panel "Mesh" de `agents_dashboard` (issue 0138) consume:
- `wg_hub::wg_peers` (peers vivos + last_handshake + tx/rx).
- `wg_hub::device_audit` (replica de audit chains — para verificacion offline).
- `agents_and_robots::room_devices` (mapping rooms ↔ devices).
- `agents_and_robots::approvals_pending` (queue de approvals pendientes).
### Capability growth log
`v0.1.0 (2026-05-24)` — initial spec mesh/1.