--- 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 < ~/.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 = \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 ` 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 ` → 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-:organic-machine.com`. - `role='container'`: para modo "deep" docker (containers como peers WG). Alias `#cont-: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 ` | `shell.exec` | `{argv: [...], cwd?: string}` | argv splits por shlex, sin shell wrapping | | `!fs.read [bytes]` | `fs.read` | `{path, max_bytes?}` | default max_bytes = manifest.max_bytes | | `!fs.write <<` | `fs.list` | `{path}` | output: array de {name,type,size} | | `!docker exec ` | `docker.container.exec` | `{container, argv}` | container debe estar en `containers_allowed` | | `!docker logs [tail]` | `docker.container.logs` | `{container, tail?, follow?}` | `--follow` activa SSE | | `!docker ps` | `docker.container.list` | `{}` | output: tabla containers vivos | | `!approve ` | (meta) | `{request_id}` | solo en `role='approval'`, solo del operator_matrix_id | | `!deny ` | (meta) | `{request_id, reason?}` | idem | | `!revoke ` | (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 `
...
` (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
` 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___` 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.