diff --git a/.gitignore b/.gitignore index 2d302dcd8..f0a542500 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ # Simulation report files *reports*/*.txt +reports/ +scenarios/ +tmp_configs/ + +# Local experiment scripts +*.bat +*.py # Javadocs *.html diff --git a/src/core/Message.java b/src/core/Message.java index d037b890b..65525fbfd 100644 --- a/src/core/Message.java +++ b/src/core/Message.java @@ -10,6 +10,8 @@ import java.util.Map; import java.util.Set; +import routing.podc.ProofEntry; + /** * A message that is created at a node or passed between nodes. */ @@ -49,6 +51,9 @@ public class Message implements Comparable { /** Application ID of the application that created the message */ private String appID; + /** Proof-of-delivery chain appended at each forward hop (PoDC Phase 1). */ + private List routeProof; + static { reset(); DTNSim.registerForReset(Message.class.getCanonicalName()); @@ -77,6 +82,7 @@ public Message(DTNHost from, DTNHost to, String id, int size) { this.requestMsg = null; this.properties = null; this.appID = null; + this.routeProof = new ArrayList(); Message.nextUniqueId++; addNodeOnPath(from); @@ -269,6 +275,10 @@ protected void copyFrom(Message m) { updateProperty(key, m.getProperty(key)); } } + + this.routeProof = m.routeProof != null + ? new ArrayList(m.routeProof) + : new ArrayList(); } /** @@ -360,4 +370,28 @@ public void setAppID(String appID) { this.appID = appID; } + /** Appends a proof entry to this message's delivery chain. */ + public void addProof(ProofEntry entry) { + this.routeProof.add(entry); + } + + /** Returns the live list of proof entries (source → receiver order). */ + public List getRouteProof() { + return this.routeProof; + } + + /** Number of proof entries currently in the chain. */ + public int getProofLength() { + return this.routeProof.size(); + } + + /** + * Rough byte cost of the proof chain (for overhead metrics). + * Each entry: 64-byte Ed25519 signature + 44-byte X.509 public key + * + ~20 bytes (nodeId, timestamp, prevHash overhead) ≈ 128 bytes. + */ + public int getProofSizeEstimate() { + return this.routeProof.size() * 128; + } + } diff --git a/src/routing/PoDCRouter.java b/src/routing/PoDCRouter.java new file mode 100644 index 000000000..0d4e52272 --- /dev/null +++ b/src/routing/PoDCRouter.java @@ -0,0 +1,550 @@ +package routing; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Signature; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.crypto.spec.SecretKeySpec; + +import core.Connection; +import core.DTNHost; +import core.DTNSim; +import core.Message; +import core.MessageListener; +import core.Settings; +import core.SimClock; +import core.SimScenario; + +import routing.podc.AckMessage; +import routing.podc.E2ECrypto; +import routing.podc.PoDCMetrics; +import routing.podc.ProofEntry; +import routing.util.RoutingInfo; + +/** + * Proof-of-Delivery-Chain router — reputation-filtered epidemic + * with Ed25519-signed proof chains. + * + *

Routing strategy

+ *
    + *
  • Data messages use epidemic-style replication with + * a lightweight Score filter that excludes only nodes whose + * reputation falls significantly below the sender's.
  • + *
  • ACK messages are forwarded without score filtering + * to ensure timely work crediting.
  • + *
+ * + *

Score formula

+ * {@code Score(X) = alpha * Work(X) + beta * Connectivity(X)} + * + *

Settings (namespace {@code Group.PoDCRouter.*})

+ * + * + * + * + * + *
alpha weight of Work in Score (0.7)
beta weight of Connectivity (0.3)
decayLambda exponential decay for work credits (0.01)
dropThreshold fractional score drop for exclusion (0.5)
+ */ +public class PoDCRouter extends ActiveRouter { + + // ── static lifecycle ────────────────────────────────────────────── + static { DTNSim.registerForReset(PoDCRouter.class.getCanonicalName()); } + + private static final Set PROCESSED_ACK_KEYS = + ConcurrentHashMap.newKeySet(); + private static final AtomicBoolean END_FLUSHED = + new AtomicBoolean(false); + + public static void reset() { + PROCESSED_ACK_KEYS.clear(); + END_FLUSHED.set(false); + ADDRESS_BOOK.clear(); + PoDCMetrics.get().reset(); + } + + // ── constants / setting keys ───────────────────────────────────── + public static final String SETTINGS_NS = "PoDCRouter"; + public static final String ALPHA_S = "alpha"; + public static final String BETA_S = "beta"; + public static final String DECAY_LAMBDA_S = "decayLambda"; + public static final String DROP_THRESHOLD_S = "dropThreshold"; + + private static final String ACK_PREFIX = "PoDC_ACK_"; + private static final int ACK_SIZE = 64; + + public static final String PROP_IS_ACK = "podc_isAck"; + public static final String PROP_ACK_PAYLOAD = "podc_ackPayload"; + + // ── per-instance config (immutable) ────────────────────────────── + private final double alpha, beta, decayLambda; + private final double dropThreshold; + + // ── Ed25519 key pair (unique per node) ─────────────────────────── + private final PrivateKey ed25519Private; + private final byte[] ed25519PublicEncoded; + + // ── X25519 key pair for E2E encryption ───────────────────────── + private final PrivateKey x25519Private; + private final byte[] x25519PublicEncoded; + + /** + * Address book: host name -> X25519 public key. + * In PoDC the public key is the node identity/address — + * every node registers once at init, like a wallet address in crypto. + * This is NOT a trusted third party; it simply models the fact that + * public keys are public (derivable from a known address). + */ + private static final Map ADDRESS_BOOK = + new ConcurrentHashMap(); + + public static final String PROP_E2E_CIPHERTEXT = "podc_e2e_ct"; + public static final String PROP_E2E_SENDER_PUB = "podc_e2e_senderPub"; + public static final String PROP_E2E_DECRYPTED = "podc_e2e_plain"; + + // ── per-instance mutable state ─────────────────────────────────── + private double work; + private int maxNeighborsSeen; + private long ackTransfersReceived; + private int nodeDataForwards; + private int deliveryContributions; + + private final Map pendingAcks = + new ConcurrentHashMap(); + private final Set ackIssuedIds = + ConcurrentHashMap.newKeySet(); + + // ── constructors ───────────────────────────────────────────────── + + public PoDCRouter(Settings s) { + super(s); + String p = SETTINGS_NS + "."; + alpha = s.getDouble(p + ALPHA_S, 0.7); + beta = s.getDouble(p + BETA_S, 0.3); + decayLambda = s.getDouble(p + DECAY_LAMBDA_S, 0.01); + dropThreshold = s.getDouble(p + DROP_THRESHOLD_S, 0.5); + + KeyPair kp = generateEd25519KeyPair(); + ed25519Private = kp.getPrivate(); + ed25519PublicEncoded = kp.getPublic().getEncoded(); + KeyPair xkp = E2ECrypto.generateKeyPair(); + x25519Private = xkp.getPrivate(); + x25519PublicEncoded = xkp.getPublic().getEncoded(); + initState(); + } + + protected PoDCRouter(PoDCRouter proto) { + super(proto); + alpha = proto.alpha; + beta = proto.beta; + decayLambda = proto.decayLambda; + dropThreshold = proto.dropThreshold; + + KeyPair kp = generateEd25519KeyPair(); + ed25519Private = kp.getPrivate(); + ed25519PublicEncoded = kp.getPublic().getEncoded(); + KeyPair xkp = E2ECrypto.generateKeyPair(); + x25519Private = xkp.getPrivate(); + x25519PublicEncoded = xkp.getPublic().getEncoded(); + initState(); + } + + private void initState() { + work = 0; + maxNeighborsSeen = 0; + ackTransfersReceived = 0; + nodeDataForwards = 0; + deliveryContributions = 0; + } + + @Override + public PoDCRouter replicate() { return new PoDCRouter(this); } + + // ── message creation ───────────────────────────────────────────── + + @Override + public boolean createNewMessage(Message m) { + if (!isAck(m)) encryptPayload(m); + boolean ok = super.createNewMessage(m); + if (ok && !isAck(m)) { + PoDCMetrics.get().recordCreated(); + } + return ok; + } + + // ── E2E encryption (X25519 + AES-256-GCM) ──────────────────────── + + private void encryptPayload(Message m) { + DTNHost recipient = m.getTo(); + if (recipient == null) return; + byte[] recipientPub = ADDRESS_BOOK.get(recipient.toString()); + if (recipientPub == null) return; + + String plain = "FROM:" + getHost() + "|TO:" + recipient + + "|ID:" + m.getId() + + "|T:" + String.format("%.0f", SimClock.getTime()); + SecretKeySpec key = E2ECrypto.deriveKey(x25519Private, recipientPub); + byte[] ct = E2ECrypto.encrypt(key, plain.getBytes(StandardCharsets.UTF_8)); + m.updateProperty(PROP_E2E_CIPHERTEXT, ct); + m.updateProperty(PROP_E2E_SENDER_PUB, x25519PublicEncoded); + } + + private String tryDecrypt(Message m) { + Object ctObj = m.getProperty(PROP_E2E_CIPHERTEXT); + Object spObj = m.getProperty(PROP_E2E_SENDER_PUB); + if (!(ctObj instanceof byte[]) || !(spObj instanceof byte[])) + return null; + SecretKeySpec key = E2ECrypto.deriveKey(x25519Private, (byte[]) spObj); + byte[] plain = E2ECrypto.decrypt(key, (byte[]) ctObj); + return plain != null ? new String(plain, StandardCharsets.UTF_8) : null; + } + + // ── Ed25519 utilities ──────────────────────────────────────────── + + private static KeyPair generateEd25519KeyPair() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (GeneralSecurityException e) { + throw new AssertionError("Ed25519 unavailable", e); + } + } + + private static final boolean FAST_CRYPTO = + Boolean.getBoolean("podc.fastCrypto"); + + protected byte[] sign(String data) { + if (FAST_CRYPTO) return new byte[64]; + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519Private); + signer.update(data.getBytes(StandardCharsets.UTF_8)); + return signer.sign(); + } catch (GeneralSecurityException e) { + return new byte[0]; + } + } + + protected byte[] getEd25519PublicEncoded() { + return ed25519PublicEncoded; + } + + // ── proof chain ────────────────────────────────────────────────── + + protected void addProofToMessage(Message msg) { + String id = getHost().toString(); + if (msg.getProofLength() > 0 + && msg.getRouteProof().get(msg.getProofLength() - 1) + .getNodeId().equals(id)) { + return; + } + long ts = (long) SimClock.getTime(); + String prev = msg.getProofLength() == 0 + ? "0" + : msg.getRouteProof().get(msg.getProofLength() - 1) + .computeHash(); + String dataToSign = id + "|" + ts + "|" + prev; + byte[] sig = sign(dataToSign); + msg.addProof(new ProofEntry(id, ts, prev, sig, ed25519PublicEncoded)); + } + + // ── transfer ───────────────────────────────────────────────────── + + @Override + protected int startTransfer(Message m, Connection con) { + if (!isAck(m)) addProofToMessage(m); + int ret = super.startTransfer(m, con); + if (ret == RCV_OK) { + PoDCMetrics.get().recordForwarded(); + if (!isAck(m)) { + nodeDataForwards++; + PoDCMetrics.get().recordForwardEvent( + SimClock.getTime(), work); + } + } + return ret; + } + + // ── ACK creation ───────────────────────────────────────────────── + + private void sendAck(Message delivered) { + long now = (long) SimClock.getTime(); + AckMessage ack = AckMessage.fromDeliveredMessage( + delivered, now, ed25519Private, ed25519PublicEncoded); + pendingAcks.put(ack.getOriginalMessageId(), ack); + + Message m = new Message(getHost(), delivered.getFrom(), + ACK_PREFIX + delivered.getId(), ACK_SIZE); + m.updateProperty(PROP_IS_ACK, Boolean.TRUE); + m.updateProperty(PROP_ACK_PAYLOAD, ack); + createNewMessage(m); + } + + // ── message received ───────────────────────────────────────────── + + @Override + public Message messageTransferred(String id, DTNHost from) { + Message m = super.messageTransferred(id, from); + if (m == null) return null; + + if (isAck(m)) { + ackTransfersReceived++; + processAck(m); + } else if (m.getTo() == getHost()) { + if (ackIssuedIds.add(m.getId())) { + String decrypted = tryDecrypt(m); + if (decrypted != null) { + m.updateProperty(PROP_E2E_DECRYPTED, decrypted); + } + double latency = SimClock.getTime() - m.getCreationTime(); + PoDCMetrics.get().recordDelivered( + latency, m.getProofLength(), m.getProofSizeEstimate()); + for (routing.podc.ProofEntry pe : m.getRouteProof()) { + DTNHost relay = resolveHost(pe.getNodeId()); + if (relay != null) { + MessageRouter rr = relay.getRouter(); + if (rr instanceof PoDCRouter) { + ((PoDCRouter) rr).deliveryContributions++; + } + } + } + sendAck(m); + } + } + return m; + } + + // ── ACK processing & work credits ──────────────────────────────── + + protected void processAck(Message ackMsg) { + Object raw = ackMsg.getProperty(PROP_ACK_PAYLOAD); + if (!(raw instanceof AckMessage)) return; + AckMessage ack = (AckMessage) raw; + + String key = ack.getOriginalMessageId() + ":" + ack.getTimestamp(); + if (!PROCESSED_ACK_KEYS.add(key)) return; + + if (!ack.verifyChain()) { + PoDCMetrics.get().recordInvalidAck(); + PROCESSED_ACK_KEYS.remove(key); + return; + } + + PoDCMetrics.get().recordAckProcessed(); + pendingAcks.remove(ack.getOriginalMessageId()); + + List path = ack.getConfirmationPath(); + for (int i = 0; i < path.size(); i++) { + DTNHost host = resolveHost(path.get(i).getNodeId()); + if (host == null) continue; + applyWork(host, computeContribution(i, ack.getTimestamp())); + } + } + + public double computeContribution(int indexFromReceiver, long ackTs) { + double pw = 1.0 / (indexFromReceiver + 1); + double delta = SimClock.getTime() - ackTs; + return pw * Math.exp(-decayLambda * delta); + } + + private void applyWork(DTNHost host, double delta) { + MessageRouter r = host.getRouter(); + if (r instanceof PoDCRouter && delta > 0 && !Double.isNaN(delta)) { + ((PoDCRouter) r).work += delta; + } + } + + // ── score ──────────────────────────────────────────────────────── + + /** + * {@code Score(X) = alpha * Work(X) + beta * Connectivity(X)}. + */ + public double getScoreForNode(DTNHost node) { + if (node == null) return 0; + MessageRouter r = node.getRouter(); + if (r instanceof PoDCRouter) { + PoDCRouter pr = (PoDCRouter) r; + return alpha * pr.work + beta * pr.connectivity(); + } + return 0; + } + + public double getConnectivity() { + return connectivity(); + } + + double connectivity() { + int n = getHost().getConnections().size(); + return maxNeighborsSeen > 0 + ? Math.min(1.0, (double) n / maxNeighborsSeen) + : Math.min(1.0, n / 10.0); + } + + // ── forwarding decision ────────────────────────────────────────── + + protected boolean shouldForward(Message msg, DTNHost neighbor) { + double myScore = getScoreForNode(getHost()); + if (myScore <= 0) return true; + return getScoreForNode(neighbor) >= myScore * (1.0 - dropThreshold); + } + + // ── main update loop ───────────────────────────────────────────── + + @Override + public void changedConnection(Connection con) { + super.changedConnection(con); + int n = getConnections().size(); + if (n > maxNeighborsSeen) maxNeighborsSeen = n; + } + + @Override + public void init(DTNHost host, List mListeners) { + super.init(host, mListeners); + int n = getConnections().size(); + if (n > maxNeighborsSeen) maxNeighborsSeen = n; + ADDRESS_BOOK.put(host.toString(), x25519PublicEncoded); + } + + @Override + public void update() { + super.update(); + flushMetricsOnceAtEnd(); + + if (isTransferring() || !canStartTransfer()) return; + if (exchangeDeliverableMessages() != null) return; + tryAllMessagesToAllConnections(); + } + + @Override + protected Message tryAllMessages(Connection con, List messages) { + DTNHost nb = con.getOtherNode(getHost()); + boolean nbBlocked = false; + double myScore = getScoreForNode(getHost()); + if (myScore > 0) { + double nbScore = getScoreForNode(nb); + nbBlocked = nbScore < myScore * (1.0 - dropThreshold); + } + for (Message m : messages) { + if (!isAck(m) && nbBlocked) continue; + int retVal = startTransfer(m, con); + if (retVal == RCV_OK) return m; + else if (retVal > 0) return null; + } + return null; + } + + // ── helpers ─────────────────────────────────────────────────────── + + static boolean isAck(Message m) { + return Boolean.TRUE.equals(m.getProperty(PROP_IS_ACK)); + } + + private DTNHost resolveHost(String nodeId) { + SimScenario sc = SimScenario.getInstance(); + if (sc == null || nodeId == null) return null; + for (DTNHost h : sc.getHosts()) { + if (h.toString().equals(nodeId)) return h; + } + return null; + } + + // ── end-of-sim metrics ─────────────────────────────────────────── + + private void flushMetricsOnceAtEnd() { + SimScenario sc = SimScenario.getInstance(); + if (sc == null) return; + if (SimClock.getTime() + 0.001 < sc.getEndTime()) return; + if (END_FLUSHED.compareAndSet(false, true)) { + PoDCMetrics.get().printSummary(); + PoDCMetrics.get().flush(sc.getName()); + } + } + + // ── GUI routing info ───────────────────────────────────────────── + + @Override + public RoutingInfo getRoutingInfo() { + RoutingInfo top = super.getRoutingInfo(); + + RoutingInfo podc = new RoutingInfo("--- PoDC Status ---"); + podc.addMoreInfo(new RoutingInfo(String.format( + "Work = %.5f", work))); + podc.addMoreInfo(new RoutingInfo(String.format( + "Score = %.5f (alpha=%.1f, beta=%.1f)", + getScoreForNode(getHost()), alpha, beta))); + podc.addMoreInfo(new RoutingInfo(String.format( + "Connectivity = %.3f (neighbors=%d, max=%d)", + connectivity(), + getHost().getConnections().size(), maxNeighborsSeen))); + podc.addMoreInfo(new RoutingInfo( + "ACKs received = " + ackTransfersReceived + + " | pending = " + pendingAcks.size())); + podc.addMoreInfo(new RoutingInfo( + "Forwards = " + nodeDataForwards + + " | delivery contributions = " + deliveryContributions)); + top.addMoreInfo(podc); + + RoutingInfo msgs = new RoutingInfo( + "--- Messages (" + getNrofMessages() + ") ---"); + for (Message m : getMessageCollection()) { + if (isAck(m)) continue; + StringBuilder sb = new StringBuilder(); + sb.append(m.getId()); + + int pl = m.getProofLength(); + if (pl > 0) { + sb.append(" [").append(pl).append(" hops: "); + for (int i = 0; i < pl; i++) { + if (i > 0) sb.append("->"); + sb.append(m.getRouteProof().get(i).getNodeId()); + } + sb.append("]"); + } + + Object ct = m.getProperty(PROP_E2E_CIPHERTEXT); + if (ct instanceof byte[]) { + String plain = tryDecrypt(m); + if (plain != null) { + sb.append(" E2E:OPEN \"").append(plain).append("\""); + } else { + sb.append(" E2E:LOCKED (").append(((byte[]) ct).length).append("B)"); + } + } + msgs.addMoreInfo(new RoutingInfo(sb.toString())); + } + top.addMoreInfo(msgs); + + RoutingInfo neighbors = new RoutingInfo( + "--- Neighbor Scores ---"); + for (Connection c : getConnections()) { + DTNHost nb = c.getOtherNode(getHost()); + neighbors.addMoreInfo(new RoutingInfo(String.format( + "%s score=%.4f%s", + nb, getScoreForNode(nb), + shouldForward(null, nb) ? "" : " [BLOCKED]"))); + } + top.addMoreInfo(neighbors); + + return top; + } + + // ── accessors ──────────────────────────────────────────────────── + + public double getWork() { return work; } + public double getAlpha() { return alpha; } + public double getBeta() { return beta; } + public int getNodeForwards() { return nodeDataForwards; } + public int getDeliveryContributions() { return deliveryContributions; } + + @Override + public String toString() { + return "PoDCRouter@" + getHost() + + " w=" + String.format("%.3f", work); + } +} diff --git a/src/routing/SybilPoDCRouter.java b/src/routing/SybilPoDCRouter.java new file mode 100644 index 000000000..ce52d9fd0 --- /dev/null +++ b/src/routing/SybilPoDCRouter.java @@ -0,0 +1,83 @@ +package routing; + +import core.DTNHost; +import core.Message; +import core.Settings; +import core.SimClock; +import core.SimScenario; + +import routing.podc.ProofEntry; + +import java.util.List; +import java.util.Random; + +/** + * A malicious PoDC router that tries to inflate its own {@code work} + * by injecting forged {@link ProofEntry} records. + * + *

Attack vector (with Ed25519)

+ * When forwarding a data message the Sybil node inserts an extra + * proof entry with a spoofed node id (picked randomly from the + * scenario's host list). The entry is signed with this node's + * Ed25519 private key and carries this node's public key. + * + *

Because {@link ProofEntry#verify()} only checks that the signature + * matches the embedded public key (no global PKI), the forged entry + * passes cryptographic verification. However, the public key does + * not belong to the claimed {@code nodeId}, so a system with + * identity-binding checks would detect the forgery.

+ */ +public class SybilPoDCRouter extends PoDCRouter { + + private final Random rng = new Random(); + + public SybilPoDCRouter(Settings s) { super(s); } + + protected SybilPoDCRouter(SybilPoDCRouter proto) { super(proto); } + + @Override + public SybilPoDCRouter replicate() { + return new SybilPoDCRouter(this); + } + + /** + * Injects a forged proof entry with a randomly chosen victim node id + * (signed with this Sybil node's own key) before the + * legitimate proof entry for this host. + */ + @Override + protected void addProofToMessage(Message msg) { + DTNHost victim = pickRandomVictim(); + if (victim != null) { + String fakeId = victim.toString(); + long ts = (long) SimClock.getTime(); + String prev = msg.getProofLength() == 0 + ? "0" + : msg.getRouteProof() + .get(msg.getProofLength() - 1).computeHash(); + String dataToSign = fakeId + "|" + ts + "|" + prev; + byte[] sig = sign(dataToSign); + msg.addProof(new ProofEntry( + fakeId, ts, prev, sig, getEd25519PublicEncoded())); + } + super.addProofToMessage(msg); + } + + private DTNHost pickRandomVictim() { + SimScenario sc = SimScenario.getInstance(); + if (sc == null) return null; + List hosts = sc.getHosts(); + if (hosts.size() <= 1) return null; + DTNHost victim; + do { + victim = hosts.get(rng.nextInt(hosts.size())); + } while (victim == getHost()); + return victim; + } + + @Override + public String toString() { + return "SybilPoDCRouter@" + getHost() + + " w=" + String.format("%.3f", getWork()); + } +} diff --git a/src/routing/podc/AckMessage.java b/src/routing/podc/AckMessage.java new file mode 100644 index 000000000..7931027a0 --- /dev/null +++ b/src/routing/podc/AckMessage.java @@ -0,0 +1,125 @@ +package routing.podc; + +import core.Message; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.security.Signature; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Acknowledgement payload carried inside a control {@link Message}. + * + * When the final recipient accepts a data message it builds an + * {@code AckMessage} via {@link #fromDeliveredMessage}. The + * {@link #confirmationPath} is the delivered message's + * {@link Message#getRouteProof()} reversed (receiver → source) with + * a freshly computed hash chain. Each entry in the reverse chain is + * signed by the ACK creator (the final recipient) using Ed25519. + */ +public class AckMessage { + + private final String originalMessageId; + private final List confirmationPath; + private final long timestamp; + + /* ---- factory ---- */ + + /** + * Reverses the forward proof chain and re-links the hashes. + * Each new entry is signed by the ACK creator's Ed25519 key. + * + * @param delivered the delivered data message + * @param now simulation timestamp for the ACK + * @param signerPrivate Ed25519 private key of the ACK creator + * @param signerPubEncoded X.509-encoded public key of the ACK creator + */ + public static AckMessage fromDeliveredMessage( + Message delivered, long now, + PrivateKey signerPrivate, byte[] signerPubEncoded) { + List fwd = delivered.getRouteProof(); + List rev = new ArrayList(); + String prev = "0"; + for (int i = fwd.size() - 1; i >= 0; i--) { + ProofEntry e = fwd.get(i); + String data = e.getNodeId() + "|" + e.getTimestamp() + "|" + prev; + byte[] sig = signData(signerPrivate, data); + ProofEntry step = new ProofEntry( + e.getNodeId(), e.getTimestamp(), prev, + sig, signerPubEncoded); + rev.add(step); + prev = step.computeHash(); + } + return new AckMessage(delivered.getId(), rev, now); + } + + private static final boolean FAST_CRYPTO = + Boolean.getBoolean("podc.fastCrypto"); + + private static byte[] signData(PrivateKey key, String data) { + if (FAST_CRYPTO) return new byte[64]; + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(key); + signer.update(data.getBytes(StandardCharsets.UTF_8)); + return signer.sign(); + } catch (GeneralSecurityException e) { + return new byte[0]; + } + } + + /* ---- constructors ---- */ + + public AckMessage(String originalMessageId, + List confirmationPath, long timestamp) { + this.originalMessageId = originalMessageId; + this.confirmationPath = confirmationPath != null + ? new ArrayList(confirmationPath) + : new ArrayList(); + this.timestamp = timestamp; + } + + /* ---- verification ---- */ + + /** + * Validates both structural integrity (hash chain) and cryptographic + * integrity (Ed25519 signature) of every entry in the path. + * + *
    + *
  1. First entry's prevHash must be {@code "0"}.
  2. + *
  3. Each subsequent entry's prevHash must equal the preceding + * entry's {@link ProofEntry#computeHash()}.
  4. + *
  5. {@link ProofEntry#verify()} must return {@code true} for + * every entry (Ed25519 signature check).
  6. + *
+ */ + public boolean verifyChain() { + List path = confirmationPath; + if (path.isEmpty()) return true; + for (int i = 0; i < path.size(); i++) { + ProofEntry e = path.get(i); + if (i == 0) { + if (!"0".equals(e.getPrevHash())) return false; + } else { + if (!e.getPrevHash().equals(path.get(i - 1).computeHash())) + return false; + } + if (!e.verify()) return false; + } + return true; + } + + /* ---- accessors ---- */ + + public String getOriginalMessageId() { return originalMessageId; } + public long getTimestamp() { return timestamp; } + + public List getConfirmationPath() { + return Collections.unmodifiableList(confirmationPath); + } + + public int getHopCount() { return confirmationPath.size(); } +} diff --git a/src/routing/podc/E2ECrypto.java b/src/routing/podc/E2ECrypto.java new file mode 100644 index 000000000..52ef84f79 --- /dev/null +++ b/src/routing/podc/E2ECrypto.java @@ -0,0 +1,88 @@ +package routing.podc; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; + +/** + * End-to-end encryption using X25519 ECDH key agreement + AES-256-GCM. + * + *

In PoDC the X25519 public key is the node address — every + * node knows every other node's public key by definition (like a + * wallet address in crypto). No key-exchange protocol is needed.

+ * + *
+ *   shared = X25519(privSender, pubRecipient)
+ *         == X25519(privRecipient, pubSender)
+ *   key   = SHA-256(shared)
+ *   ct    = AES-256-GCM(key, iv, plaintext)
+ * 
+ */ +public final class E2ECrypto { + + private static final int GCM_TAG_BITS = 128; + private static final int IV_BYTES = 12; + + private E2ECrypto() {} + + public static KeyPair generateKeyPair() { + try { + return KeyPairGenerator.getInstance("X25519").generateKeyPair(); + } catch (GeneralSecurityException e) { + throw new AssertionError("X25519 unavailable", e); + } + } + + public static SecretKeySpec deriveKey(PrivateKey myPrivate, + byte[] otherPublicEnc) { + try { + KeyFactory kf = KeyFactory.getInstance("X25519"); + PublicKey otherPub = kf.generatePublic( + new X509EncodedKeySpec(otherPublicEnc)); + KeyAgreement ka = KeyAgreement.getInstance("X25519"); + ka.init(myPrivate); + ka.doPhase(otherPub, true); + byte[] shared = ka.generateSecret(); + byte[] aesKey = MessageDigest.getInstance("SHA-256").digest(shared); + return new SecretKeySpec(aesKey, "AES"); + } catch (GeneralSecurityException e) { + throw new RuntimeException("ECDH key derivation failed", e); + } + } + + /** @return iv (12 B) || ciphertext+GCM-tag */ + public static byte[] encrypt(SecretKeySpec key, byte[] plaintext) { + try { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + byte[] iv = new byte[IV_BYTES]; + SecureRandom.getInstanceStrong().nextBytes(iv); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = c.doFinal(plaintext); + byte[] out = new byte[IV_BYTES + ct.length]; + System.arraycopy(iv, 0, out, 0, IV_BYTES); + System.arraycopy(ct, 0, out, IV_BYTES, ct.length); + return out; + } catch (GeneralSecurityException e) { + throw new RuntimeException("AES-GCM encrypt failed", e); + } + } + + /** @return plaintext, or null if tag verification fails */ + public static byte[] decrypt(SecretKeySpec key, byte[] packed) { + try { + if (packed == null || packed.length < IV_BYTES + GCM_TAG_BITS / 8) + return null; + byte[] iv = Arrays.copyOfRange(packed, 0, IV_BYTES); + byte[] ct = Arrays.copyOfRange(packed, IV_BYTES, packed.length); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + return c.doFinal(ct); + } catch (GeneralSecurityException e) { + return null; + } + } +} diff --git a/src/routing/podc/PoDCMetrics.java b/src/routing/podc/PoDCMetrics.java new file mode 100644 index 000000000..9c3444cb5 --- /dev/null +++ b/src/routing/podc/PoDCMetrics.java @@ -0,0 +1,282 @@ +package routing.podc; + +import core.DTNHost; +import core.SimClock; +import core.SimScenario; +import routing.MessageRouter; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Collects per-simulation PoDC metrics and writes CSV output when + * {@link #flush(String)} is called (typically at the end of the run). + * + *

In addition to aggregate stats, records per-forward-event data + * (simulation time, forwarder's work) so that a "natural selection" + * chart can be generated post-hoc.

+ */ +public final class PoDCMetrics { + + /* ---- singleton ---- */ + private static final PoDCMetrics INSTANCE = new PoDCMetrics(); + public static PoDCMetrics get() { return INSTANCE; } + private PoDCMetrics() { reset(); } + + /* ---- raw accumulators ---- */ + private int created; + private int delivered; + private long relayed; + private long rejected; + private long acksProcessed; + private long invalidAcks; + + private final List latencies = new ArrayList(); + private final List hopCounts = new ArrayList(); + private final List proofBytes = new ArrayList(); + + private static final int BIN_SIZE = 60; + private final List forwardBins = new ArrayList(); + + private int lastGiniBin = -1; + private final List giniBins = new ArrayList(); + + /* ---- recording API ---- */ + + public void recordCreated() { created++; } + + public void recordDelivered(double latency, int hops, int proofSize) { + delivered++; + latencies.add(latency); + hopCounts.add(hops); + proofBytes.add(proofSize); + } + + public void recordForwarded() { relayed++; } + public void recordRejected() { rejected++; } + public void recordAckProcessed() { acksProcessed++; } + public void recordInvalidAck() { invalidAcks++; } + + /** + * Records a data-message forward event for time-series analysis. + * @param simTime current simulation time + * @param forwarderWork the forwarding node's accumulated work + */ + public void recordForwardEvent(double simTime, double forwarderWork) { + int bin = (int) (simTime / BIN_SIZE); + while (forwardBins.size() <= bin) { + forwardBins.add(new int[]{0, 0}); + } + int[] slot = forwardBins.get(bin); + slot[0]++; + if (forwarderWork > 0) slot[1]++; + + if (bin > lastGiniBin) { + lastGiniBin = bin; + while (giniBins.size() <= bin) giniBins.add(0.0); + giniBins.set(bin, giniWork()); + } + } + + /* ---- computed metrics ---- */ + + public double deliveryRatio() { + return created == 0 ? 0 : (double) delivered / created; + } + + public double overheadRatio() { + return delivered == 0 ? Double.NaN + : (double)(relayed - delivered) / delivered; + } + + public double avgLatency() { + return avg(latencies); + } + + public double avgHops() { + double s = 0; + for (int h : hopCounts) s += h; + return hopCounts.isEmpty() ? 0 : s / hopCounts.size(); + } + + public double avgProofBytes() { + double s = 0; + for (int b : proofBytes) s += b; + return proofBytes.isEmpty() ? 0 : s / proofBytes.size(); + } + + public double giniWork() { + SimScenario sc = SimScenario.getInstance(); + if (sc == null) return 0; + List works = new ArrayList(); + for (DTNHost h : sc.getHosts()) { + MessageRouter r = h.getRouter(); + if (r instanceof routing.PoDCRouter) { + works.add(((routing.PoDCRouter) r).getWork()); + } + } + return gini(works); + } + + /* ---- CSV output ---- */ + + private static final String HEADER = + "scenario,sim_time,nodes,created,delivered,delivery_ratio," + + "overhead_ratio,avg_latency,avg_hops,gini_work," + + "avg_proof_bytes,total_forwarded,total_rejected," + + "total_acks,total_invalid_acks"; + + public void flush(String scenarioName) { + String dir = "reports"; + new File(dir).mkdirs(); + + String path = dir + "/podc_results.csv"; + boolean needsHeader = !new File(path).exists(); + try (PrintWriter pw = new PrintWriter(new FileWriter(path, true))) { + if (needsHeader) pw.println(HEADER); + int nodes = 0; + SimScenario sc = SimScenario.getInstance(); + if (sc != null) nodes = sc.getHosts().size(); + pw.printf("%s,%.1f,%d,%d,%d,%.6f,%.4f,%.2f,%.2f,%.6f,%.1f,%d,%d,%d,%d%n", + scenarioName, SimClock.getTime(), nodes, + created, delivered, deliveryRatio(), overheadRatio(), + avgLatency(), avgHops(), giniWork(), avgProofBytes(), + relayed, rejected, acksProcessed, invalidAcks); + } catch (IOException e) { + System.err.println("PoDCMetrics: cannot write CSV — " + e); + } + + flushWorkTimeSeries(scenarioName, dir); + flushGiniTimeSeries(scenarioName, dir); + flushPerMessageData(scenarioName, dir); + flushPerNodeData(scenarioName, dir); + } + + /** + * Writes {@code reports/_work_timeseries.csv} with columns: + * time_bin_start, total_forwards, experienced_forwards, fraction_experienced + * + * "Experienced" = forwarder had work>0 (i.e. received at least one ACK). + */ + private void flushWorkTimeSeries(String scenarioName, String dir) { + String path = dir + "/" + scenarioName + "_work_timeseries.csv"; + try (PrintWriter pw = new PrintWriter(new FileWriter(path))) { + pw.println("time,total_forwards,experienced_forwards,fraction_experienced"); + for (int i = 0; i < forwardBins.size(); i++) { + int[] slot = forwardBins.get(i); + double frac = slot[0] > 0 ? (double) slot[1] / slot[0] : 0; + pw.printf("%d,%d,%d,%.4f%n", + i * BIN_SIZE, slot[0], slot[1], frac); + } + } catch (IOException e) { + System.err.println("PoDCMetrics: cannot write timeseries — " + e); + } + } + + private void flushGiniTimeSeries(String scenarioName, String dir) { + String path = dir + "/" + scenarioName + "_gini_timeseries.csv"; + try (PrintWriter pw = new PrintWriter(new FileWriter(path))) { + pw.println("time,gini"); + for (int i = 0; i < giniBins.size(); i++) { + pw.printf("%d,%.6f%n", i * BIN_SIZE, giniBins.get(i)); + } + } catch (IOException e) { + System.err.println("PoDCMetrics: cannot write gini timeseries — " + e); + } + } + + private void flushPerMessageData(String scenarioName, String dir) { + String path = dir + "/" + scenarioName + "_per_message.csv"; + try (PrintWriter pw = new PrintWriter(new FileWriter(path))) { + pw.println("latency,hops,proof_bytes"); + int n = latencies.size(); + for (int i = 0; i < n; i++) { + pw.printf("%.2f,%d,%d%n", + latencies.get(i), + i < hopCounts.size() ? hopCounts.get(i) : 0, + i < proofBytes.size() ? proofBytes.get(i) : 0); + } + } catch (IOException e) { + System.err.println("PoDCMetrics: cannot write per-message data — " + e); + } + } + + public void flushPerNodeData(String scenarioName, String dir) { + SimScenario sc = SimScenario.getInstance(); + if (sc == null) return; + String path = dir + "/" + scenarioName + "_per_node.csv"; + try (PrintWriter pw = new PrintWriter(new FileWriter(path))) { + pw.println("node_id,work,score,connectivity,forwards,delivery_contributions"); + for (DTNHost h : sc.getHosts()) { + MessageRouter r = h.getRouter(); + if (r instanceof routing.PoDCRouter) { + routing.PoDCRouter pr = (routing.PoDCRouter) r; + pw.printf("%s,%.6f,%.6f,%.4f,%d,%d%n", + h.toString(), + pr.getWork(), + pr.getScoreForNode(h), + pr.getConnectivity(), + pr.getNodeForwards(), + pr.getDeliveryContributions()); + } + } + } catch (IOException e) { + System.err.println("PoDCMetrics: cannot write per-node data — " + e); + } + } + + public void printSummary() { + System.out.println("--- PoDC metrics summary ---"); + System.out.printf(" created : %d%n", created); + System.out.printf(" delivered : %d%n", delivered); + System.out.printf(" delivery_ratio : %.4f%n", deliveryRatio()); + System.out.printf(" overhead_ratio : %.4f%n", overheadRatio()); + System.out.printf(" avg_latency (s) : %.2f%n", avgLatency()); + System.out.printf(" avg_hops : %.2f%n", avgHops()); + System.out.printf(" gini_work : %.6f%n", giniWork()); + System.out.printf(" avg_proof_bytes : %.1f%n", avgProofBytes()); + System.out.printf(" total_forwarded : %d%n", relayed); + System.out.printf(" total_rejected : %d%n", rejected); + System.out.printf(" total_acks : %d%n", acksProcessed); + System.out.printf(" total_invalid : %d%n", invalidAcks); + } + + public void reset() { + created = delivered = 0; + relayed = rejected = acksProcessed = invalidAcks = 0; + latencies.clear(); + hopCounts.clear(); + proofBytes.clear(); + forwardBins.clear(); + giniBins.clear(); + lastGiniBin = -1; + } + + /* ---- helpers ---- */ + + private static double avg(List v) { + if (v.isEmpty()) return 0; + double s = 0; + for (double d : v) s += d; + return s / v.size(); + } + + static double gini(List values) { + int n = values.size(); + if (n == 0) return 0; + List sorted = new ArrayList(values); + Collections.sort(sorted); + double sum = 0, cumSum = 0; + for (int i = 0; i < n; i++) { + sum += sorted.get(i); + cumSum += sorted.get(i) * (i + 1); + } + if (sum == 0) return 0; + return (2.0 * cumSum) / (n * sum) - (n + 1.0) / n; + } +} diff --git a/src/routing/podc/ProofEntry.java b/src/routing/podc/ProofEntry.java new file mode 100644 index 000000000..d6a802b27 --- /dev/null +++ b/src/routing/podc/ProofEntry.java @@ -0,0 +1,108 @@ +package routing.podc; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; + +/** + * A single record in a message's proof-of-delivery chain. + * + * Each forwarding hop appends one {@code ProofEntry} to the message. + * The chain is linked via {@link #prevHash}: the first entry uses + * {@code "0"}, each subsequent entry stores the SHA-256 hash of + * its predecessor. + * + *

The {@link #signature} is a 64-byte Ed25519 digital signature over + * the canonical data string {@code nodeId|timestamp|prevHash}. + * {@link #publicKey} carries the signer's X.509-encoded Ed25519 public + * key so that any node can call {@link #verify()} without a global PKI.

+ */ +public class ProofEntry { + + private final String nodeId; + private final long timestamp; + private final String prevHash; + private final byte[] signature; + private final byte[] publicKey; + private transient String cachedHash; + + /** + * @param nodeId {@code DTNHost.toString()} of the forwarding node + * @param timestamp simulation second (truncated from {@code SimClock}) + * @param prevHash {@link #computeHash()} of the previous entry, or + * {@code "0"} for the very first entry + * @param signature Ed25519 signature (64 bytes) over + * {@code nodeId|timestamp|prevHash} + * @param publicKey X.509-encoded Ed25519 public key of the signer + */ + public ProofEntry(String nodeId, long timestamp, String prevHash, + byte[] signature, byte[] publicKey) { + this.nodeId = nodeId; + this.timestamp = timestamp; + this.prevHash = prevHash; + this.signature = signature != null ? signature.clone() : new byte[0]; + this.publicKey = publicKey != null ? publicKey.clone() : new byte[0]; + } + + /** Canonical data string used for both signing and hashing. */ + private String canonicalData() { + return nodeId + "|" + timestamp + "|" + prevHash; + } + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + /** SHA-256 hex digest of {@link #canonicalData()}. */ + public String computeHash() { + if (cachedHash != null) return cachedHash; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] dig = md.digest( + canonicalData().getBytes(StandardCharsets.UTF_8)); + char[] hex = new char[dig.length * 2]; + for (int i = 0; i < dig.length; i++) { + int v = dig[i] & 0xFF; + hex[i * 2] = HEX[v >>> 4]; + hex[i * 2 + 1] = HEX[v & 0x0F]; + } + cachedHash = new String(hex); + return cachedHash; + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 unavailable", e); + } + } + + /** + * Verifies the Ed25519 signature against this entry's + * {@link #publicKey} and {@link #canonicalData()}. + * + * @return {@code true} only if the signature is cryptographically valid + */ + private static final boolean FAST_CRYPTO = + Boolean.getBoolean("podc.fastCrypto"); + + public boolean verify() { + if (FAST_CRYPTO) return true; + try { + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + PublicKey pk = kf.generatePublic( + new X509EncodedKeySpec(publicKey)); + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(pk); + verifier.update(canonicalData().getBytes(StandardCharsets.UTF_8)); + return verifier.verify(signature); + } catch (GeneralSecurityException e) { + return false; + } + } + + public String getNodeId() { return nodeId; } + public long getTimestamp() { return timestamp; } + public String getPrevHash() { return prevHash; } + public byte[] getSignature() { return signature.clone(); } + public byte[] getPublicKey() { return publicKey.clone(); } +}