Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 26 min read

Discovery Is Not Authentication: Designing a Local-Network File Transfer System in Rust

Lanweave is an open-source Rust application that sends files and folders between devices on the same local network. Two people run the terminal UI, one selects the other's device, they pair with a one-time code, and eith

Lanweave is an open-source Rust application that sends files and folders between devices on the same local network. Two people run the terminal UI, one selects the other's device, they pair with a one-time code, and either side can propose files; the recipient reviews the full list and chooses where the files land. There is no account, no cloud service, and no background daemon. The device that starts a connection is the initiator; the device that accepts it is the responder.

This article is an engineering case study of that architecture: how discovery, encryption, pairing, per-transfer approval, session lifecycle, and filesystem handling fit together โ€” and where the guarantees stop.

Status, stated up front: Lanweave is a work in progress, has not been audited, and is not safe for sensitive files. The pairing implementation is a prototype on an unaudited PAKE library, and the project keeps a release gate open pending specialist review. Nothing below claims the system is secure; the goal is to explain the reasoning and name what is not established. The repository is the authority for every statement: github.com/etim66/lanweave.

The problem, stated precisely

The goal is narrow: move files between two devices on the same LAN, using only those devices, with clear consent at every step. That narrowness is what makes the security design interesting โ€” the usual shortcut, a server both sides trust, is not available.

The constraints I set for the first version:

  • No central infrastructure. The app must work on an isolated network with no internet access and no coordination server.
  • No persistent trust. Authorization belongs to one live connection: no trusted-device database, no reusable certificate, no reconnect token.
  • The LAN is hostile input. Any device can publish discovery records, copy display names, open connections, and send arbitrary bytes.
  • Received data is untrusted until proven otherwise. A filename on the wire is not a path; a byte count on the wire is not the size of what arrives.
  • Bounded everything. Memory, queues, parse sizes, prompt lifetimes, and idle time all have fixed limits, because "the peer behaves" is not something the receiver controls.

Those constraints split the problem into separate questions:

  • Discovery and authentication: where might a peer be, and is this the device the user intends?
  • Authorization: may this peer send these files?
  • Confidentiality and integrity: who can read the bytes, and is what arrived what was sent?
  • Lifecycle: how long does any of this last?
  • Filesystem safety: what happens to the bytes after they arrive?

That decomposition produces the central claim of this article:

Discovering a device on a network is not the same thing as authenticating it.

mDNS answers where a listener might be, not who operates it. Any device can advertise _lanweave._tcp.local., copy a display name, or point a connection at an endpoint it controls. A design that treats "it appeared in the device list" as trust would fail immediately. In Lanweave, discovery produces candidates; only pairing turns a candidate into the intended connection.

flowchart LR
    subgraph LAN["LAN โ€” untrusted"]
        MDNS[mDNS records<br/>spoofable, unauthenticated]
        PEER[Any TCP peer<br/>arbitrary bytes]
    end

    subgraph APP["Lanweave process"]
        DISC[Discovery<br/>candidate list, bounds, escaping]
        TLS[Transport<br/>TLS 1.3, fresh identity per connection]
        PAIR[Pairing<br/>one-time code + PAKE confirmation]
        SESS[Protocol state machine<br/>authorized idle session]
        REVIEW[Transfer review<br/>manifest + local accept/reject]
        STORE[Storage<br/>validate โ†’ temp file<br/>โ†’ verify โ†’ publish]
    end

    USER[Local user decisions]

    MDNS --> DISC
    DISC -->|select a candidate| PEER
    PEER --> TLS --> PAIR
    USER -->|accept or reject request| PAIR
    PAIR --> SESS --> REVIEW
    USER -->|approve manifest, choose directory| REVIEW --> STORE

The rest of this article walks down that stack, focusing on what messages are allowed to mean. The wire format itself โ€” frame boundaries, decoding, streaming without loading whole files into memory โ€” belongs to the companion article, TCP Is a Byte Stream: Designing a Framed Application Protocol in Rust with Tokio.

System shape

Lanweave is one Cargo package: a private library and a thin binary. The modules that matter here are discovery, transport, pairing, protocol, session, transfer, and storage, composed by a bootstrap module that owns task startup and ordered shutdown.

Two architectural rules shape the security reasoning:

  • One task owns each connection and its mutable state. Reader, writer, file I/O, and timer code talk to that owner through bounded channels; the UI gets a read-only snapshot and never mutates live session state.
  • Layers cannot make decisions that belong to other layers. Protocol validation has no dependency on the TUI, sockets, mDNS, or the filesystem. Transport carries frames but does not decide consent. Storage validates names but never invents overwrite or rename behavior. The TUI renders state but contains no protocol rules.

That separation makes the state machine testable without a network, the frame decoder fuzzable without a filesystem, and "who is allowed to decide this?" answerable one layer at a time.

Discovery: a candidate list, not a trust list

Lanweave advertises and browses a DNS-SD service, _lanweave._tcp.local., using mDNS, so devices appear without configuration (RFC 6762, RFC 6763). The SRV record supplies the host and port. The TXT record carries exactly one value:

Key Value Meaning
v 1 The listener speaks experimental protocol version 1

The service instance is the local computer name, sanitized into a DNS label but keeping its casing, so the list stays human-readable. The host record is not the computer name: it is a per-run lowercase label with a random suffix, because reusing the computer name can collide with the platform's own responder. The app also tracks its own advertisement aliases and mDNS name-conflict events, so it does not list itself as a peer.

Why mDNS? With no server in the design, there are three options:

  • Manual host:port entry avoids multicast, but users must find and share addresses.
  • A coordination service assumes a reachable coordinator, contradicting the isolated-network requirement.
  • Zero-configuration discovery (mDNS/DNS-SD) is already deployed on most LANs and needs no server, configuration, or internet connection.

I chose the third; direct host:port entry remains a fallback, not a replacement.

What mDNS is not. mDNS records are unauthenticated by design โ€” a naming mechanism, not a security mechanism. Any device can publish a record, copy a name, or flood the network with noise.

When multicast fails. Guest Wi-Fi and VLAN policies sometimes block multicast, so the app accepts a direct host:port that skips mDNS and nothing else: pairing, the one-time code, and transfer approval still apply.

IPv4 only, for now. This version disables IPv6 in the listener and the discovery adapter: the listener binds an IPv4 wildcard socket on an ephemeral port, and the mDNS adapter disables IPv6 interfaces rather than relying on dual-stack defaults. That is a v1 limitation, not a claimed capability.

The candidate store bounds the number of devices, the endpoints per device, and the byte length of service, host, and interface names, so hostile advertisements cannot become a resource problem. Names are untrusted display text: control characters and Unicode bidirectional controls are escaped into visible \u{XXXX} sequences before rendering, and the device list labels them untrusted rather than treating them as identity. When a device disappears, any selection of it is cleared, so a stale row cannot connect to a device that has been replaced.

Discovery is not a runtime dependency of an established session: once two peers have paired, losing the mDNS record neither closes nor alters the session. The connection, not the record, is the unit of trust.

The encrypted channel: TLS before any trust exists

The order of operations is deliberate: TCP connects, TLS 1.3 completes, and only then does any application message flow. ALPN carries the identifier lanweave/1, and the connection is TLS 1.3 only: both sides configure rustls with TLS 1.3 as the sole version, and the initiator's verifier fails closed if a TLS 1.2 handshake signature reaches it (RFC 8446).

Why TLS rather than a custom encrypted transport? Because there is no version of "we'll implement our own record layer" that ends well. TLS 1.3 has been analysed, implemented, and deployed by people whose full-time job is not a file-sharing app, and Rust has a mature implementation in rustls. Confidentiality is needed before the pairing code is used, and it must not depend on anything Lanweave-specific. Pairing then confirms which connection this is; it does not need to invent encryption.

Identity is the interesting question, because two devices that have never met have no certificates to verify against each other. Lanweave's answer has three parts:

  1. The responder generates a fresh self-signed P-256 certificate and key for every connection (via rcgen). Nothing is reused, so there is no long-lived local key to steal and nothing to pin.
  2. The initiator's verifier is deliberately narrow. A trust-chain check would fail โ€” the certificate is self-signed โ€” and a server-name check would be meaningless, since the name is not an identity. The verifier relaxes only those two checks. It still requires a single well-formed X.509 end-entity certificate (no intermediates) and still verifies the TLS 1.3 CertificateVerify signature with a fixed ECDSA P-256 / SHA-256 scheme, proving the responder holds the certificate's private key.
  3. The certificate is never treated as an identity. It is a per-connection encryption key; user-visible identity comes from pairing.
// The provisional initiator verifier: chain and server-name checks are
// relaxed, every other check is kept.
fn verify_server_cert(
    &self,
    end_entity: &CertificateDer<'_>,
    intermediates: &[CertificateDer<'_>],
    _server_name: &ServerName<'_>,
    _ocsp_response: &[u8],
    _now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
    // The profile expects exactly one fresh end-entity certificate: a peer
    // that sends intermediates presents an unexpected chain shape.
    if !intermediates.is_empty() {
        return Err(TlsError::InvalidCertificate(CertificateError::UnknownIssuer));
    }
    // Required shape: a parseable X.509 end-entity certificate.
    EndEntityCert::try_from(end_entity)
        .map_err(|_| TlsError::InvalidCertificate(CertificateError::BadEncoding))?;
    Ok(ServerCertVerified::assertion())
}
// The handshake signature must use the fixed P-256 scheme, and the signature
// must verify: proof of private-key possession.
fn verify_tls13_signature(
    &self,
    message: &[u8],
    cert: &CertificateDer<'_>,
    dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
    if dss.scheme != SignatureScheme::ECDSA_NISTP256_SHA256 {
        return Err(TlsError::PeerMisbehaved(
            PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme,
        ));
    }
    let cert = EndEntityCert::try_from(cert)
        .map_err(|_| TlsError::InvalidCertificate(CertificateError::BadEncoding))?;
    cert.verify_signature(webpki::ring::ECDSA_P256_SHA256, message, dss.signature())
        .map_err(|_| TlsError::InvalidCertificate(CertificateError::BadSignature))?;
    Ok(HandshakeSignatureValid::assertion())
}

Two further details matter. First, resumption, session tickets, pre-shared keys, 0-RTT and early application data are all disabled, on both sides. Every connection gets fresh TLS state and a fresh key schedule, so no abbreviated handshake can bypass a future user decision. The server stores no sessions and sends no tickets; the client disables resumption.

Second, ALPN is enforced explicitly after the handshake. rustls alone does not fail when the peer sends no ALPN, so the code checks the negotiated protocol and rejects anything but exactly lanweave/1. Tests cover mismatched and absent ALPN on both sides.

After the handshake, each side derives a fresh 32-byte TLS exporter using export_keying_material with the lanweave/v1 label (RFC 8446 ยง7.5). The exporter is not a file key; it is one input to the pairing confirmation below, which binds the act of pairing to this TLS connection and no other.

Honesty note: the custom verifier and the TLS/pairing composition are flagged for specialist review. A verifier that relaxes chain checks is a sharp tool; the narrowness of the remaining checks is what makes it reviewable at all โ€” but no review has happened yet.

Pairing: turning a connection into the intended connection

Lanweave separates three decisions that must never imply one another:

Decision Who makes it What it grants
Pairing decision The responder's user accepts or rejects the request Nothing by itself
Code authorization The responder's user shares a one-time code; the initiator enters it An authorized session on this connection
Transfer decision The recipient's user reviews the manifest Permission to send exactly that file list

Accepting a pairing request does not authorize the session; the code does. An authorized session does not approve any files; the recipient does that separately for every transfer.

The one-time code

The pairing code is generated only after the responder's user accepts a live request. It is eight decimal digits, kept in memory, valid for 120 seconds, usable for exactly one cryptographic pairing attempt, and never sent as a protocol field. One screen displays it, the other types it, and the humans move it through some private channel. The specification forbids it in discovery, logs, or any normal wire message.

Generation uses the operating system's cryptographically secure generator with rejection sampling, so every value in 00000000..=99999999 is equally likely and leading zeroes stay significant. A PairingCode zeroizes its digits on drop, has no Display implementation, and prints as PairingCode([REDACTED]) in Debug output. A failed pairing does not reveal whether the code was wrong, expired, already used, or bound to a different connection.

// Uniform sampling over the ten-million-value code space.
const LIMIT: u64 = (1u64 << 32) - (1u64 << 32) % CODE_RANGE as u64;
let value = loop {
    let sample = u64::from(rng.next_u32());
    if sample < LIMIT {
        break (sample % u64::from(CODE_RANGE)) as u32;
    }
};

An eight-digit code is about 26.6 bits of entropy. That is small, and the design does not pretend otherwise: the defence is not that the code resists offline guessing, but that there is one online attempt per accepted request, a wrong guess destroys the code and closes the connection, and the lifetime is short. The human ceremony โ€” the responder's user must accept before a code even exists โ€” is what keeps an attacker from grinding guesses.

Mutual confirmation with SPAKE2

The cryptographic step is an RFC 9382 SPAKE2 exchange using the P-256 / SHA-256 / HKDF / HMAC ciphersuite (RFC 9382). The pairing initiator is Party A, the responder is Party B, and the identity strings (lanweave-v1-initiator, lanweave-v1-responder) are fixed, not negotiated.

Why a PAKE? Sending the code (or its hash) over the connection would turn it into a bearer token: anyone who learns it โ€” including an intermediary relaying the connection โ€” could complete the exchange, and anyone who observes or relays the transcript could verify guesses offline. A PAKE prevents both: an observer or an attacker without the code cannot verify a guess, and a wrong guess yields confirmation failure rather than a reusable signal. Lanweave does not implement the group arithmetic; the adapter wraps an existing crate, and the project forbids implementing elliptic-curve arithmetic itself.

The code is mapped to the SPAKE2 password scalar as w = OS2IP(digits) mod n. Because the code space is small and the group order is large, this mapping is injective over all ten million codes. The mapping is small, reviewable, and flagged for specialist review.

Binding pairing to the connection

A confirmation that only proves "both sides know a code" would be vulnerable to a split-connection relay: an intermediary that sits between the two peers and pairs separately with each side. Lanweave binds the confirmation to the live connection by constructing additional authenticated data from four length-prefixed fields:

u32 length + "lanweave-v1-pairing"
u32 length + 32-byte TLS exporter
u32 length + exact initiator hello body
u32 length + exact responder hello body
pub(crate) fn binding(exporter: &[u8], initiator_hello: &[u8], responder_hello: &[u8]) -> Result<Zeroizing<Vec<u8>>, PairingError> {
    if exporter.len() != EXPORTER_LEN
        || initiator_hello.len() > MAX_HELLO_BODY_BYTES
        || responder_hello.len() > MAX_HELLO_BODY_BYTES
    {
        return Err(PairingError::InvalidInput);
    }
    let mut binding = Zeroizing::new(Vec::new());
    push_field(&mut binding, BINDING_LABEL);        // b"lanweave-v1-pairing"
    push_field(&mut binding, exporter);
    push_field(&mut binding, initiator_hello);
    push_field(&mut binding, responder_hello);
    Ok(binding)
}

The length prefixes mean no combination of fields can collide with another. The hello bodies are used exactly as encoded on the wire โ€” the received bytes for the peer's message, the same deterministic encoding for the local one โ€” and are bounded to 4,000 bytes. The SPAKE2 adapter passes this value as associated data, which RFC 9382 mixes into the confirmation key derivation, so a pairing completed across two different TLS connections cannot produce matching confirmations. That composition is flagged for adversarial testing.

The exchange itself is four records: initiator share, responder share, initiator confirmation, responder confirmation. The ordering rules ensure that no side considers the session authorized until it has verified the peer and is sure its own final confirmation is on the wire:

  • The responder consumes the code only after the initiator's confirmation verifies.
  • The initiator treats the session as authorized only after the responder's confirmation verifies.
  • The responder writes and flushes its confirmation before reporting the session authorized, using a transport barrier that resolves only when the frame has actually been written and flushed. If the final confirmation is lost, pairing fails safely and the code is not reusable.
sequenceDiagram
    actor U1 as User A
    participant I as Initiator app
    participant R as Responder app
    actor U2 as User B
    U1->>I: select a discovered device
    I->>R: TLS 1.3 (ALPN lanweave/1), hello
    R-->>I: hello
    I->>R: pair_request
    R->>U2: show pairing request
    U2-->>R: accept
    R->>R: generate one-time code
    R-->>I: pair_response(accepted)
    R->>U2: display code locally
    U2->>U1: share code through a private channel
    U1->>I: enter code
    I->>R: pairing share (Party A)
    R-->>I: pairing share (Party B)
    I->>R: pairing confirmation (must flush)
    R-->>I: pairing confirmation (must flush)
    Note over I,R: session authorized<br/>code consumed, never sent

Prototype status. The SPAKE2 adapter wraps pakery-spake2 and pakery-crypto, newer and unaudited crates selected for the fixed ciphersuite and its point validation. The release gate stays open until independent review accepts the dependency, the password mapping, and the exporter/hello binding. Until then, this is a plausible cryptographic story, not an established one.

Transfer authorization: consent per payload

An authorized session is not a file permit. After pairing, the session is idle, and either participant may propose one transfer at a time. Pairing roles โ€” initiator and responder โ€” stay fixed, but transfer roles change per request: whoever proposes is the requester, and whoever reviews is the recipient. Lanweave avoids "sender" and "receiver" as session-wide identities, because those names quietly assume a direction that does not exist.

A transfer request carries the complete ordered manifest: one to 1,024 entries, each a filename component and an exact byte size, plus optional folder metadata. It carries no local paths, timestamps, permissions, media types, file IDs, or pre-transfer hashes. The recipient sees the whole list โ€” names, sizes, count, checked total, and the requester's untrusted display name โ€” and accepts or rejects it as a unit. There is no partial approval, no remote rename, no overwrite, and the destination directory is chosen locally and never communicated to the peer.

Two details keep the manifest honest. The requester re-checks every source before sending it: if a file no longer matches the approved name, type, or size, it sends transfer_cancel rather than silently changing the manifest, which is immutable once sent. And a request that cannot be stored is rejected before any bytes move: the recipient validates every name, checks for conflicts and existing destinations, and prepares the first temporary file before accepting. If preparation fails, the rejection carries a specific reason (invalid_filename, name_conflict, destination_exists, unavailable, โ€ฆ) and no data is sent.

Because both peers can propose while idle, the protocol needs a rule for simultaneous requests. Version 1 has no request IDs, so the pairing initiator's proposal wins. If the responder has a proposal pending when the initiator's request arrives, it withdraws and re-queues its own proposal, records that exactly one stale busy response is owed, and reviews the initiator's request. It consumes that stale response in whatever phase the winning transfer is in, then may send its queued proposal once the session returns to idle. The rule applies only to simultaneous proposals, not as permanent initiator priority.

The cost is real: request IDs and a transaction layer would keep both proposals in flight and generalize failure handling. But "one active proposal, fixed priority, one tracked stale response" is a smaller state space โ€” and the price is that the responder may have to wait and retry.

Session lifecycle and failure scopes

The session state machine is where the security properties become operational. Its rules fit in a short list:

  • Pairing must finish before the session becomes active.
  • Only one transfer request or transfer is active at a time.
  • Either participant can become the requester for the next transfer.
  • Transfer completion or rejection returns to session idle.
  • Protocol, authentication, and transport failures close the session.
  • Closing drops all temporary authorization and requires fresh pairing.

Failures are scoped in three different ways:

Outcome Scope
Pairing rejection, failure, expiry, or cancellation Close the connection
Malformed framing, unsafe input, wrong-state controls Close the connection
Transfer rejection (including busy) End the proposal; session stays idle
transfer_cancel before ready End the proposal; session stays idle
transfer_cancel after ready Clean the partial file; close the session
Failed file_result Clean the partial file; close the session
session_close, idle expiry, transport loss Close the session
stateDiagram-v2
    [*] --> session_idle
    session_idle --> awaiting_transfer_response: local transfer_request
    session_idle --> reviewing_transfer: peer transfer_request
    awaiting_transfer_response --> session_idle: rejected / cancelled before ready
    awaiting_transfer_response --> awaiting_ready: accepted
    reviewing_transfer --> session_idle: rejected
    reviewing_transfer --> receiving_file: accepted and ready
    awaiting_ready --> sending_file: ready
    sending_file --> session_idle: all files verified
    receiving_file --> session_idle: all files verified
    sending_file --> closed: failure or cancel after ready
    receiving_file --> closed: failure or cancel after ready
    session_idle --> closed: session_close / 600 s idle / transport loss
    closed --> [*]
    note right of closed
        Authorization is destroyed; the next
        connection repeats the full pairing flow.
    end note

The post-ready rule deserves its explanation, because "close the session on one failed file" looks harsh. Once the recipient has sent ready, the requester starts writing DATA frames, and a later failure or cancellation may leave bytes in flight or in the sender's queues.

Version 1 puts no transfer or file identifiers on DATA, because the connection state already supplies that context. That keeps frames small and parsers simple โ€” and it is also why in-flight data cannot be safely attributed once its transfer has ended. Closing the session prevents a later transfer from misinterpreting stale bytes. Rejection before ready has no such problem: no file data exists yet.

Time bounds are part of the lifecycle:

  • Session idle: 600 seconds, starting when pairing completes and restarting when a transfer finishes or a proposal is rejected or cancelled before ready. An active transfer is not idle, but it has its own limits.
  • Prompt and code lifetime: 120 seconds. Pairing requests, the code, and transfer approval prompts all expire, so an unattended prompt cannot hold resources forever.
  • Progress deadline: 60 seconds without progress during an active transfer. This is local policy, not a wire constant; a stalled sender or silent recipient closes the session after cleanup instead of hanging.
  • Handshake and control deadlines bound TCP/TLS setup and the initial message exchange.

Every deadline runs against a monotonic clock, so wall-clock changes cannot extend an authorization window.

Closing is explicit when the connection allows it: session_close carries one of three reasons (user_closed, idle_timeout, shutdown), is valid in any authorized state, and is not acknowledged. EOF and transport loss also end the session, but they never prove an active file succeeded. That asymmetry is intentional: absence of an error is not success.

Filesystem safety: received data is hostile input

The recipient's storage layer is where an adversarial manifest would do damage, so it is strict about names before anything is written:

  • A name must be a single non-empty filename component of at most 255 UTF-8 bytes. Empty names, ., .., absolute or path-like names, embedded separators (/, \), NUL, and control characters are rejected.
  • Platform rules apply on top. On Windows, reserved device names (CON, PRN, AUX, NUL, COM1โ€“COM9, LPT1โ€“LPT9), trailing dots or spaces, and forbidden characters (< > : " | ? *) are rejected, and equivalence compares case-insensitively without trailing dots or spaces.
  • Duplicate or equivalent names within one manifest, and names that already exist in the destination, reject the whole request. Existence is checked with symlink_metadata, which does not follow links.

Data is written only to a temporary file: tempfile creates an unpredictable .lanweave-*.part name in the destination directory, without following links, and with mode 0600 on Unix. Nothing appears under the final name until the exact declared byte count and the SHA-256 digest both match.

// Flush, sync, then publish without replacing anything.
file.flush().await?;
file.sync_all().await?;
drop(file);

match temp.persist_noclobber(&final_path) {
    Ok(_) => Ok(()),
    Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
        Err(StorageError::DestinationExists)
    }
    Err(_) if std::fs::symlink_metadata(&final_path).is_ok() => {
        Err(StorageError::DestinationExists)
    }
    Err(_) => Err(StorageError::Io),
}

persist_noclobber makes the final step a no-replace operation: if a destination appears after the manifest check, finalization fails instead of overwriting it. Lanweave never overwrites and never silently renames, and files are written one at a time in manifest order โ€” the sender waits for each file's verified result before starting the next.

Failure cleanup is defined by what the user keeps:

  • The current partial file is deleted.
  • Files already verified in this transfer remain.
  • Later manifest entries are not attempted.
  • The sender is told the failure while the connection is still safe.

Received files are never automatically opened, previewed, or executed. The application's job ends at "verified bytes under a safe name".

Threat model: what this protects against, and what it does not

The project's threat model assumes uncompromised endpoints, a working OS random generator, a correct TLS implementation, and users who share the code privately. Within those assumptions, a condensed excerpt:

Threat Control Remaining risk
Spoofed visible device Discovery is untrusted; pairing confirmation is required Attackers can create noise or denial of service
Misleading display name Escaped, labelled untrusted, never treated as identity Similar names may still confuse users
Pairing-request spam Bounded requests and prompts, with deadlines Attackers can consume bounded attention and resources
Pairing code disclosure Generated after acceptance, displayed locally, never sent, 120 s lifetime Anyone who sees the live code may pair
Online code guessing One cryptographic attempt per accepted request, prompt limits, short expiry Attackers can cause prompts or denial of service
Split-connection intermediary Pairing confirmation bound to the TLS exporter and exact hello bodies The property is intended, not verified
Replay Fresh TLS and pairing state, strict state, no resumption Replays still consume bounded parsing work
Malformed frames or JSON Bounded lengths and strict schemas before allocation Parser defects may remain
Terminal escape injection Control and bidi characters escaped for display Unicode look-alikes can still confuse users
Path traversal and overwrite Single safe names, platform checks, no-follow temp files, no-replace finalization Filesystem edge cases need platform tests
Corrupt transfer TLS integrity plus exact size and SHA-256 before finalization A paired peer can intentionally choose harmful content
Authorization reuse No trust database, resumption, reusable code, or reconnect token Users must repeat pairing after every disconnect

The out-of-scope list matters just as much. Lanweave cannot:

  • protect a compromised computer;
  • prove a person's real-world identity;
  • make a received file safe to open;
  • hide all traffic metadata โ€” a passive LAN observer can still see that Lanweave is running and observe connection timing and volume, even though file contents are encrypted;
  • protect a code that has been disclosed;
  • guarantee network availability, or fully prevent denial of service.

The cryptographic implementation is also unaudited, which makes the entire "confidentiality and authentication" column a design intention rather than a verified property.

Testing as evidence of correctness โ€” not of security

The project tests what is testable and is careful not to over-interpret the result. At the time of writing, cargo test --all --locked reports 229 passing tests across 32 test modules. The parts most relevant here:

  • End-to-end session tests over real connections. Two session services complete a real TLS 1.3 handshake, SPAKE2 exchange, and one-time code, then run transfers in both directions over loopback โ€” folder preparation, cancellation, timeouts, manual close โ€” with no mocked transport or pairing adapter.
  • Wrong-code and expiry tests. A wrong code never authorizes either peer; an expired code closes pairing without authorization; a timed-out prompt rejects without ever creating a code.
  • Failure-scope tests. Rejection before ready keeps the session usable; a cancel after ready cleans the partial file and closes both peers; a stalled or silent peer closes at the progress deadline.
  • Filesystem tests. Unsafe, duplicate, equivalent, existing, and path-like names are rejected; symlink and destination races fail safely; partial files are removed on drop; no-replace finalization is asserted against a raced destination.
  • Frame and parser tests. Every split point of a wire image decodes identically, byte-by-byte feeding reassembles frames, malformed headers fail before allocation, and oversized lengths are rejected regardless of how many bytes arrived. A separate fuzz target exercises the decoder with arbitrary bytes, but it is not yet in CI, and broader protocol fuzzing is planned work.

None of this proves the cryptography is right. As the project's testing document puts it, tests show that the implementation follows the draft; they do not prove that unaudited cryptography is secure. Several gates remain open: dependency review of the PAKE crate, deterministic positive and negative test vectors, certificate-verifier review, and review of the pairing/TLS composition.

Limitations and future work

Labelled as limitations, because they are:

  • No audit, prototype PAKE. The pairing dependency, password mapping, binding composition, and custom certificate verifier all need specialist review, and the app must not be described as secure or production-ready. pakery-spake2/pakery-crypto were chosen for ciphersuite fit and point validation, not for an audit history.
  • IPv4 only for now. Discovery disables IPv6 interfaces and the listener binds an IPv4 wildcard socket. IPv6 policy is future work.
  • Small code space. Eight digits is ~26.6 bits; the model relies on one attempt per accepted request and a short lifetime, not offline resistance.
  • No resume, no parallel transfers, no trusted devices. Deliberate v1 exclusions; each would need a new threat review.
  • Update channel. The self-updater trusts GitHub Releases and checksums, and Windows builds are not code-signed yet. That is a separate trust decision, documented as such.

Lessons learned

Write the trust boundary before the code. The most valuable early artifact was the sentence "discovery answers where, not who." It settled dozens of decisions โ€” what the device list may claim, what the certificate may mean, what pairing must bind to โ€” before any of them were code.

Separate decisions are stronger than one big gate. Pairing, code authorization, and transfer approval are three questions; folding them into one "trusted peer" state would have been simpler to build and worse to reason about, because a mistake in one would silently grant the others.

Failure scope is a design decision. Deciding explicitly that post-ready failures close the session โ€” and writing down why โ€” was more valuable than defensive coding. Vague failure behavior is where protocols accumulate ambiguity.

Bound everything, and test at the layer where the risk is. Per-kind frame limits, bounded queues, parsing before allocation, monotonic deadlines: each is small; together they define what a hostile peer can make the process do. The session tests use real TLS, real files, and real races; the frame tests cover every split point; the state machine is pure.

State what the system does not do. The limitations section is the part of the design most likely to be right. A tool that explains its own threat model is more trustworthy than one that asserts good intentions.

Conclusion

The design has one organising idea: never let one layer's output be mistaken for another layer's decision. Discovery produces candidates; TLS produces a confidential channel; the code produces a confirmed live connection; the recipient produces consent; the state machine produces bounded lifetimes; storage produces a verified file. Each step can fail without silently granting the next. Lanweave is unfinished and unaudited โ€” the pairing prototype and the custom certificate verifier are where that matters most โ€” but the architecture makes the remaining work identifiable instead of hidden.

Source and further reading

The implementation, protocol specification, threat model, and testing strategy are in the repository: github.com/etim66/lanweave. The protocol is documented in docs/PROTOCOL.md, the security boundary in docs/SECURITY.md, the adversary analysis in docs/THREAT_MODEL.md, and the open review gates in docs/CRYPTOGRAPHY.md.

Written by the developer of Lanweave. I build this in the open and document its design decisions so they can be reviewed, challenged, and improved; bug reports and design criticism โ€” especially a broken security or state rule with a reproducible example โ€” are the most useful feedback.

References

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.