Files
fn_registry/dev/issues/0134-mesh-protocol-spec.md
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

43 KiB

id, title, status, type, domain, scope, priority, depends, blocks, related, related_flows, created, updated, tags, flow, dependencies
id title status type domain scope priority depends blocks related related_flows created updated tags flow dependencies
0134 Mesh protocol spec: capability manifests, ed25519 envelopes, enrollment, audit chain pending spec
infra
cybersecurity
protocols
cross-app high
0135
0136
0137
0138
0139
0140
0141
0142
0143
0069
0009
2026-05-24 2026-05-24
mesh
wireguard
matrix
e2ee
ed25519
manifest
audit-chain
security
spec
agents
devices
0009

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

{
  "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

{
  "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

# 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)

{
  "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)

{
  "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_idmanifest_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)

# 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

// 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:

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)

// 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)

{
  "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)

# 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)

// 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:

{
  "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:
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

// 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

{
  "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

// 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)

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

// 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)

// 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)

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.