Skip to content

fix(runtime): bind peer credentials to claimed node identities#2665

Merged
slepp merged 16 commits into
mainfrom
fix/2652-peer-identity-binding
Jul 15, 2026
Merged

fix(runtime): bind peer credentials to claimed node identities#2665
slepp merged 16 commits into
mainfrom
fix/2652-peer-identity-binding

Conversation

@slepp

@slepp slepp commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Peer credentials are now bound to the specific node identity that claims them, closing a remote-impersonation gap where an unauthenticated peer could satisfy admission by presenting any accepted key regardless of which node it claimed to be. Node identity is now a stable, per-node Noise key (persisted across restarts), and allow_peer becomes a two-argument call — allow_peer(node_id, key) — that binds a credential to one claimed NodeId rather than to a shared allowlist. Binding is enforced consistently across the TCP-Noise and QUIC mesh transports, on both control-plane and data-plane operations (admission, inbound/outbound ask, gossip/SWIM), with a claim state machine that admits first-claim, allows same-credential reclaim, and rejects a different credential claiming an already-bound identity. Unverified/no-binding mode remains available but is now strict by default: it is loopback-scoped and delivery-only, with no non-loopback peer able to operate under an unverified identity unless explicitly configured. This is a breaking change to allow_peer's signature; existing callers must migrate to the two-argument form.

Verification

  • make ci-preflight at the gated head (fee2b45ac0): 14/14 lanes green
  • Runtime and CLI feature tests covering claim/reclaim/reject state transitions, per-transport credential isolation, strict vs. unverified posture, and stable identity across a transport flip: pass
  • Cross-process E2E migrated to mutual pre-start credential exchange over credentialed TCP-Noise connections (each node publishes and binds the peer's identity key before start): pass
  • Source and history leak scans: clean
  • Manual checks: three independent security passes plus a re-review of the two commits added after the last pass (test/config only, no production runtime source changed) confirm the gated head is security-equivalent to the reviewed snapshot

Out of scope

Fixes #2652

slepp added 16 commits July 15, 2026 09:15
Introduce the per-node PeerAuthSnapshot / public-staging PeerAuthConfig
authority that binds an authenticated peer credential (Noise static key
on TCP, certificate SPKI on quic-mesh) to the NodeId it may claim.

- PeerCredential / PeerBindings map NodeId -> {credential}.
- authorize() returns a structured PeerAuthz verdict (never a bare bool):
  a different credential claiming a bound NodeId is CredentialMismatch,
  a credential bound elsewhere is reported with its actual NodeId.
- posture_for() decides per-connection posture from the remote endpoint
  class: an unconfigured node is strict on non-loopback/Unknown and
  Unverified only on loopback (never a blanket unverified pass).
- Owner-scoped ConfigState (Building/Starting/Running) governs only the
  public Node::* API; identity material lives behind Arc for cheap,
  secret-preserving clones.

Native-only and feature-agnostic (opaque byte identity material) so it
compiles under every native feature combination. Types only; no wiring
yet. 16 unit tests.
Introduce the per-node PeerAuthSnapshot as the authority for a node's
NodeId in strict mode, replacing the process-global config as the source
of truth for who a node claims to be.

- HewConnMgr carries a frozen PeerAuthSnapshot installed at construction.
  Split hew_connmgr_new into the unchanged C ABI shim (which installs an
  unconfigured snapshot, so existing callers are untouched) and an
  internal connmgr_new that the production node-start path calls with the
  node's real snapshot. Concurrent managers hold independent snapshots;
  there is no shared admission authority across nodes.

- HewNode carries its installed snapshot. hew_node_set_auth_snapshot
  installs it while STOPPED and is refused once the node is running.
  hew_node_start validates the snapshot and, before any allocation or
  listen, reconciles node_id against the snapshot's strict binding
  identity: a zero id adopts the pinned NodeId, a conflicting explicit id
  is rejected fail-closed with a diagnostic, and a self-inconsistent
  snapshot refuses to start.

- The singleton public Node::* API stages configuration in a separate,
  owner-scoped ConfigState (Building -> Starting -> Running) guarded by
  PEER_AUTH_STATE. hew_node_api_start merges HEW_NODE_ID / HEW_DIST_
  UNVERIFIED posture, validates before allocation, freezes the snapshot
  onto the node, runs the low-level start WITHOUT holding the staging
  lock, then transitions to Running or restores the held config on
  failure — all gated on a generation+owner guard so a concurrent stage
  is never clobbered. hew_node_api_shutdown resets staging to Building
  only when the shutting node owns it, mirroring the CURRENT_NODE owner
  check; a secondary low-level stop leaves staging intact.

Default configuration (no HEW_NODE_ID, empty bindings) keeps the prior
PID-derived id and unconfigured snapshot, so existing distributed
behaviour is unchanged. Transport-selection-from-snapshot read is
deferred to the transport slice (env remains authoritative; default TCP
matches). Adds unit coverage for the NodeId authority, malformed-snapshot
refusal, the setter's running-node guard, and the public owner-scoped
staging lifecycle.

Refs #2652
…ture

Add the typed remote-endpoint accessor and wire per-connection posture into
the admission path so a connection's authority is decided from the actual
endpoint against the node's own snapshot — never a process-global authority.

- transport.rs: hew_transport_conn_remote_ip_class dispatches by ops-identity
  (no vtable change): TCP reads the stored socket's peer_addr; quic-mesh reads
  the live PeerConn::remote_address; plain-quic / stub / null yield Unknown
  (strict, fail-closed). classify_remote_socket_addr maps loopback vs routable.
- quic_mesh.rs: hew_quic_mesh_transport_conn_remote_ip_class ops-verifies the
  transport before any impl cast and classifies the resolved conn.
- connection.rs: hew_connmgr_add computes posture = mgr.auth.posture_for(class)
  after the numeric identity check and stores it on the ConnectionActor.
  Credential-free posture rejects fire before credential resolution: an
  unconfigured node (no bindings, no HEW_NODE_ID, not opt-out) on a
  non-loopback/Unknown endpoint is rejected ("requires HEW_NODE_ID"), and any
  strict connection over an Unknown-class transport (plain quic / stub) is
  rejected ("strict binding unsupported on plain quic transport"). Loopback
  endpoints on an unconfigured node stay Unverified (delivery-only), preserving
  existing loopback-dev behaviour.

The manager's `auth` field is now read (drops the transitional dead-code
allow). Credential extraction + structured authorize wire in on the mesh/Noise
slices; the claim state machine consumes the stored posture next.

Adds accessor unit coverage (loopback/routable classification, null+stub
Unknown, real TCP loopback peer_addr). Full hew-runtime lib suite green under
default and quic; builds clean under --no-default-features.

Refs #2652
Introduce the reserve -> publish -> retire claim machine that binds an
authenticated peer credential to the NodeId it is admitted under, so at
most one connection may own a NodeId's route + cluster token at a time
(issue #2652, plan decision D3).

- HewConnMgr gains a per-manager `claims: (Mutex<HashMap<u16, LiveClaim>>,
  Condvar)` table; the mutex is the single serializing guard for the
  reserve/publish/retire window and the condvar coordinates the handoff.
- reserve_claim rejects a different-credential takeover of a live Published
  claim fail-closed, supersedes a same-credential reconnect, waits (bounded)
  on an in-flight Reserved claim, and inserts a fresh reservation otherwise.
- Admission (hew_connmgr_add) resolves the peer credential (Noise static key
  under encryption; mesh SPKI deferred), authorizes the claimed NodeId against
  the frozen per-node authority, and reserves before spawning the reader.
  Reader-spawn and install failures abort the reservation, restoring any
  superseded claim.
- publish_connection_established transitions Reserved -> Published under the
  same lock as the route + cluster token, aborts if superseded meanwhile, and
  demotes a superseded same-credential connection by closing its transport.
- retire_connection_publication removes the claim only for the exact owner and
  drives route removal + MonitorLost fan-out off that removal, so a superseded
  connection no longer fabricates a lost event for a peer still live under a
  newer claim.

Adds direct claim-machine unit tests (exact-owner lifecycle, different-
credential reject, same-credential supersede+abort-restore, fresh-abort,
non-owner retire, publish-aborts-when-superseded, Reserved-wait, concurrent
exactly-one-owner) and threads a reservation through the existing publish/
retire race tests. Also stabilises the S3 tcp peer-addr classification test
by giving accept a real timeout instead of racing the client dial.
…nding

Bind a peer's authenticated credential to the NodeId it is permitted to
claim (issue #2652). allow_peer takes (node_id: u16, credential_hex:
string) and stages a PeerCredential into the pre-start Building config
instead of a global allowlist: the credential is interpreted by the
node's pinned transport (TCP => 32-byte Noise pubkey; quic-mesh => cert
SPKI; plain quic => rejected, no peer-credential channel). Reserved id 0
and this node's own HEW_NODE_ID are rejected, as is a credential already
bound to a different NodeId (one credential binds to exactly one NodeId;
rotation overlap binds multiple credentials to one NodeId).

Node::start pins the resolved transport into the frozen snapshot (so
admission interprets credentials consistently with the listener) and
bridges the snapshot's mesh SPKI allowlist into the quic-mesh listener
before it binds, so a bound peer is admitted and an unbound one rejected
(fail-closed) end-to-end.

ABI migrated atomically across catalog (U16_STRING), types registration,
runtime signature, and both native+wasm32 r4_runtime_abi goldens. The
env-merge extraction (merge_start_env_into_config) keeps start under the
line budget. E2e fixtures move to the two-arg form; the peer-auth surface
test sets HEW_NODE_ID (strict binding requires this node's own id).

Tests: hew-runtime lib green across default/quic/encryption/no-default
(2099 each; one pre-existing parallel-exec flake passes in isolation);
run_e2e peer-auth + bad-hex green; clippy -D warnings + fmt clean.
Move the QUIC-mesh listener identity, peer-SPKI allowlist, and fail-closed
setup-error poison off the four process-global statics (ACTIVE_MESH_SPKI_
ALLOWLIST, ACTIVE_MESH_IDENTITY, MESH_AUTH_SETUP_ERROR, and the shared test
lock) and onto a per-instance InstalledMeshAuth on each QuicMeshTransport.
Two concurrent mesh nodes now hold independent identities, allowlists, and
setup state with no cross-contamination (issue #2652, D14).

The install seam is hew_quic_mesh_transport_install_auth(transport, snapshot):
hew_node_start builds the transport's auth material from the node's frozen
PeerAuthSnapshot before listen, so the mTLS handshake admits exactly the
bound peers and a poisoned setup refuses to bind. mesh_identity_load_or_create
now returns typed MeshIdentityMaterial that Node::load_keys stages into the
Building config instead of writing a global; node_peer_auth_setup_failed and
the Node::start pre-flight read the per-node config setup_error rather than a
process-global failure record.

Removed the now-dead extern-C global allowlist wrappers
(hew_quic_mesh_peer_spki_add/remove/clear) and their symbol-classification
entries. Migrated the mesh unit tests and the quic_mesh_spki_allowlist
integration test to independent per-transport install, and added a
mesh_transport_auth_isolation test proving two transports stay isolated.
…S6)

Expose Node::identity_key() -> string returning this node's own stable
public credential for the pinned transport as lowercase hex (cert SPKI on
quic-mesh, Noise public key on tcp-noise), or "" before an identity is
loaded. Peers pin it via their own allow_peer, completing the credential
round-trip.

- runtime: hew_node_api_identity_key three-state read of the staging config
  (Building cfg / held Starting.config / retained Running.identity_export)
  via malloc_cstring; empty-not-null, never derefs the owner pointer.
- load_keys: early transport pin in the Building config so identity_key can
  export the pinned mesh credential before Node::start freezes the snapshot
  (idempotent with merge_start_env_into_config).
- ABI registration across layers: HIR catalog (RuntimeFfiShim), types
  registration, MIR FreshOwnedString ownership contract + CONTRACT_SYMBOLS
  (count 170->171), jit-symbol classification.
- goldens: surgical 'declare ptr @hew_node_api_identity_key()' after
  allow_peer in native + wasm32 r4_runtime_abi.ll, byte-verified against
  real codegen output (local toolchain cannot run make ll-diff; see handoff).
- tests: runtime three-state unit test (no owner deref); e2e peer-auth
  fixture asserts identity_key exports non-empty lowercase hex pre-start.

Refs #2652
…S6)

Complete the tcp-noise half of the peer-identity binding: a node now
presents a stable Noise static identity that peers can pin, and the Noise
admission pre-gate consults this node's per-node snapshot instead of a
process-global allowlist.

- encryption: noise_identity_load_or_create(path) loads (or mints and
  persists owner-only) a stable X25519 Noise keypair; an existing file is
  loaded verbatim (never re-minted), a wrong-size file rejected fail-closed.
- load_keys: transport-selected — mesh TLS identity on quic-mesh, stable
  Noise keypair on tcp-noise; plain quic rejected (no pinnable identity).
  Both stage into the Building config and pin the transport early.
- connmgr: hew_connmgr_add presents mgr.auth.noise_identity() (stable) on
  every connection instead of a fresh per-connection generate_keypair(),
  which had made a node's Noise public key unbindable. The strict Noise
  pre-gate now rejects a peer key absent from THIS node's snapshot allowlist
  (mgr.auth.noise_pubkey_allowlisted) rather than the global ACTIVE_ALLOWLIST;
  authorize() then binds the key to the claimed NodeId. Unverified (loopback
  dev / opt-out) stays delivery-only. The standalone hew_allowlist_* C API
  and its EncryptedTransport keep the global allowlist unchanged (separate
  user-facing surface, not the distributed-node path).
- tests: noise_identity_load_or_create persistence/stability + wrong-size
  reject. Downgrade/missing-credential, mismatch, unpinned-reject, pinned-
  accept and allowlist decisions remain covered by the authorize/snapshot
  unit tests (peer_binding). Full runtime lib matrix green (default / quic /
  encryption / no-default, 2100 each).

Refs #2652
…/D6)

Complete the peer-identity admission unit so an authenticated key can only
claim the NodeId it is bound to, and an Unverified connection is delivery-only
across both the control and data planes.

Control + data-plane gating (D9/D12):
- authenticated_peer_node_id_for_conn resolves control authority only for a
  Strict posture + Published claim + exact conn-id owner (supersede-safe);
  peer_node_id_for_conn stays the posture-agnostic delivery id (D9 single
  Unverified capability).
- Gate registry-gossip apply/flush, SWIM, and cluster publication behind the
  authenticated peer; inbound asks from a non-owner/Unverified peer are
  rejected with a diagnostic (no reply, no route, no membership side effect).
- PendingReply completion validates the originating (conn_mgr, conn_id); a
  reply arriving on a different connection is dropped (complete/fail_remote_reply
  now take the origin connection).

Mesh SPKI credential extraction (D6):
- hew_transport_quic_mesh_peer_spki recovers the peer pinned leaf SPKI so a
  quic-mesh Strict connection resolves PeerCredential::Spki and binds the
  claimed NodeId to the authenticated key (previously left credential-free,
  which made mesh Strict admission fail closed).

Credentialed test harnesses (no test-only posture promotion):
- start_authorized_quic_mesh_pair installs cross-bound SPKI->NodeId snapshots
  on in-process mesh nodes; the 12 two_node ask/reply/worker/SWIM mechanism
  tests now exercise a real Authorized connection.
- start_authorized_tcp_node + broker_two_process_noise_keys give the three
  cross-process tests a real bidirectional TCP-Noise key exchange, binding each
  peer Noise static key before connect.
- Unverified delivery/rejection tests retained as policy controls; added
  unverified_gating and reply_binding coverage.
…S8)

Close out the peer-identity admission unit with the wire-identity proof,
documentation, and the strengthened durable lesson.

Wire-identity E2E (BLOCK-5 point 1):
- wire_identity_peer_observes_configured_node_id asserts that after an
  authorized quic-mesh handshake the responder observes the initiator under
  its exact operator-pinned HEW_NODE_ID (not a PID-derived value), and that
  the observed id is credential-bound (authenticated), not merely declared.
  Non-vacuous: an unbound/PID-derived id would fail admission and never route.

Docs:
- HEW-DIST-SPEC.md gains 12.2 (NodeId <-> authenticated-credential binding):
  an admitted key may claim only its bound NodeId; the handshake NodeId is the
  operator-pinned id; Unverified is delivery-only across control AND data
  planes; per-node auth isolation.
- CHANGELOG.md documents the breaking two-arg Node::allow_peer signature, the
  new Node::identity_key export, and the admission-binding security model.

Lesson:
- Strengthen ctrl-frame-binds-to-authenticated-peer: "authenticated peer" is
  the exact current owner of a Published, credential-bound claim; the handshake
  NodeId must be credential-bound AND equal to the pinned HEW_NODE_ID; Unverified
  is delivery-only across both planes; reply completion validates the
  originating connection. Composes with the numeric-pin work (#2646).

Composition with #2646 (numeric pin) is automatic: every CTRL_* handler already
consumes authenticated_peer_node_id, which now delegates to the stricter
exact-owner anchor, so Unverified peers are rejected across all control frames.
CTRL_LINK_DOWN was authorised via the self-declared delivery NodeId
(peer_node_id_for_conn), which stays nonzero even for an Unverified
(delivery-only) or superseded connection. A peer whose declared id
matched a stored link's remote_node_id could therefore drive a
cross-node link-exit and crash an actor linked to the genuine, still
-alive owner, because deliver_link_down_to_ref only rejects an
authenticated peer of 0. Route the handler through the exact-owner
gate (authenticated_peer_node_id) so an Unverified/superseded frame
resolves to no identity and is dropped with a diagnostic.

Symmetrically, outbound registry gossip and SWIM membership traffic
gated only on ACTIVE + gossip-capable, so an Unverified peer could
still receive cluster state and be probed. Gate all four outbound
paths (active_gossip_connection_ids, flush_registry_gossip_to_connection,
hew_connmgr_active_swim_peers, hew_connmgr_send_swim) on the exact
authenticated claim owner, snapshotting connection ids before the
claim-lock check so it never nests inside the connections read guard.
Fire-and-forget user-message delivery to Unverified peers is unchanged.

peer_node_id_for_conn had exactly one production caller (the link_down
handler); inbound delivery routes via the admission-populated routing
table, so it becomes a test-only accessor for the wire-identity check.

Adds negative tests proving a gated link-down leaves a diagnostic for
both an Unverified and a superseded owner, and that an Unverified peer
receives no outbound gossip or SWIM while delivery stays allowed; the
existing gossip-broadcast test now seeds published claims.
The peer-authentication guide named HEW_NODE_ID but never showed how to
launch a node with it, and gave no migration path off the old one-argument
allow_peer. Add a runnable HEW_NODE_ID launch example, describe the
identity_key() credential-exchange workflow, and document replacing every
one-argument allow_peer(node_id) call with the two-argument
allow_peer(node_id, credential_hex) form (the one-argument form no longer
compiles; a malformed credential fails closed at Node::start).

The distributed_hello example now prints identity_key() and shows the
HEW_NODE_ID launch line so the credential-pinning workflow is demonstrable
end to end. Corrects the changelog to state that Unverified peers neither
inject nor receive registry gossip or SWIM membership.
An outbound ask is control-bearing: its reply deposits into the local
pending table and its dispatch runs a handler on the peer. It must go
only to the exact authenticated owner (Strict posture + published claim)
of the target NodeId. Previously setup_remote_ask serialized and sent
the ask regardless of the target connection's posture; an unverified or
superseded target silently dropped the inbound ask, so the initiator
saw only a Timeout, with reply-completion validation the sole backstop.

Gate at initiation: reject with AskError::Unauthorized when
authenticated_peer_node_id_for_conn != target_node_id, before any bytes
are serialized or a pending reply is registered. This is the outbound
symmetric of inbound_ask_denied_unverified.

Migrate the five pending-ask tests that drove asks over unverified
loopback-TCP connections onto a genuinely credentialed pair
(start_authorized_tcp_pair, cross-pinned Noise identities). Add explicit
coverage: an unverified outbound ask returns Unauthorized, and the
outbound gate authorizes only the exact owner (unverified/superseded
rejected).
Node::set_transport only wrote the process-global HEW_TRANSPORT env. It
could flip the transport after Node::allow_peer / Node::load_keys had
already staged credentials — which allow_peer/load_keys interpret under
the pinned transport (a 32-byte Noise pubkey on TCP vs a cert SPKI on
quic-mesh). A later flip silently reinterpreted the staged material,
and merge_start_env_into_config then unconditionally overrode the
config transport from env at start, so the started node could bind a
transport inconsistent with its staged identity.

Pin the selection in the Building config and reject a post-staging
change:
- add PeerAuthConfig::has_staged_credentials (bindings or a stable
  Noise/mesh identity present);
- set_transport now acquires the staging lock and, when credentials are
  already staged, refuses fail-closed (no env mutation) any change to a
  different transport; an idempotent same-transport re-selection is
  allowed; on success it pins cfg.transport as well as the env;
- allow_peer pins cfg.transport after staging (idempotent with
  load_keys / set_transport);
- merge_start_env_into_config treats a pinned selection as authoritative
  (env wins only when unpinned) and re-asserts the effective selection
  onto HEW_TRANSPORT so the low-level transport construction builds the
  stored selection.

Tests: a post-staging set_transport is rejected and changes neither the
pinned config nor HEW_TRANSPORT; the exported identity_key and the
pinned selection start will use are stable across a rejected flip.
The peer credential/NodeId binding work changed the ABI-classified symbol
set in scripts/jit-symbol-classification.toml, so the count of classified-
but-uncontracted symbols the verifier tracks moved from 987 to 985.

Audit of the two classification changes (net -2):

  +1  hew_node_api_identity_key — new #[no_mangle] runtime export
      (Node::identity_key exporting this node's stable public credential).
      It is classified `stable` and carries no ownership contract, exactly
      like every sibling hew_node_api_* symbol and hew_uint_to_string: the
      owned-string allocation contract (allocate via malloc_cstring / free
      via hew_string_drop) is proven by the runtime FFI round-trip tests
      (node_identity_key_reads_three_state_config_without_owner_deref,
      identity_key_stable_across_attempted_transport_flip), not by a
      jit-symbol ownership contract. So it adds 1 uncontracted symbol.

  -3  hew_quic_mesh_peer_spki_add / _remove / _clear — retired exports.
      The D14 per-node transport-auth isolation moved the mesh SPKI
      allowlist off the process-global ACTIVE_* surface onto per-node
      snapshots, so these three #[no_mangle] exports no longer exist in
      hew-runtime. They were classified `stable` and uncontracted, so
      removing them drops 3 uncontracted symbols.

987 + 1 - 3 = 985. The verifier confirms 985 with no "unclassified
runtime exports" or "not ABI-classified" errors, i.e. the classification
still exactly matches the live runtime export set.
Under strict peer binding, plain-TCP loopback connections run Unverified
(delivery-only): they cannot resolve the registry, ask, or gossip. The
distributed two-process E2E suite still stood up plain-TCP nodes, so every
cross-process scenario that needs a lookup now correctly fails closed with
`lookup-unresolved` (ask, node identity/location, stale ref, remote
monitor clean-exit/crash/connection-drop/setup-after-drop, watcher-node
death, link cascade clean-exit/crash/partition/non-crashlinked, cross-node
monitor-close reclaim, wire CBOR round-trip, partition ask).

Migrate the whole shared harness to real authorized TCP-Noise:

- Fixture (`dist_node.hew`): each node mints a stable Noise identity via
  `Node::load_keys`, publishes its `Node::identity_key()` to a shared
  key-exchange dir (atomic temp-then-rename), waits for the peer's, and
  binds it with `allow_peer(peer_node_id, key)` BEFORE `Node::start` — a
  real mutual authenticated pin, never an injected posture. The server
  signals listener liveness via a `server.ready` file (the client cannot
  observe the harness READY line, and a failed one-shot connect is not
  retried); the client connects to `1@127.0.0.1:<port>` so admission binds
  the server's claimed NodeId to its authenticated key.

- Harness: a `SecureScenario` context owns the key-exchange dir and pins
  each role's `HEW_NODE_ID` (server 1, client 2, required by strict mode).
  All five scenario runners launch BOTH processes concurrently — the
  server cannot reach READY until the client has published its credential
  (each side stages `allow_peer` before start), so the old
  launch-then-wait-then-launch ordering would deadlock.

Strict mode is never relaxed and no test-only posture is injected; the
separate Unverified delivery-only runtime tests are untouched.

Refs #2652
@slepp slepp merged commit 3683c2a into main Jul 15, 2026
12 checks passed
@slepp slepp deleted the fix/2652-peer-identity-binding branch July 15, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

distributed: bind authenticated peer keys to claimed NodeIds

1 participant