---
title: 'Symmetric and Asymmetric Encryption'
source: 'https://academia.sh/en/courses/application-protocols/symmetric-and-asymmetric-encryption'
course: 'Application Layer Protocols'
language: en
updated: '2026-08-17T18:07:03+00:00'
license: 'CC BY-SA 4.0'
---

# Symmetric and Asymmetric Encryption

The roles of the two key models: asymmetric in setup, symmetric in transport. The round-trip cost of setup count per exchange, and the visibility the intermediary loses.

Every decision counted so far rested on a single assumption: the intermediary can read the
message. Because it read the method, the path, the private marker, and the validator, it
could take the `repeatable`, `cacheable`, and `fresh` decisions with no extra round trip. This
assumption has a cost, and the cost is silent: the layer that can read the message is not only
the intermediary in the scenario, it is **every** layer on the path.

This lesson's question is what mechanism makes the message unreadable, and what it costs. The
measure does not change — we still count round trips and decisions. What changes is that what
is measured is no longer a field but a **role**: which key model runs at which step, and how
many rounds that step holds.

## Two Key Models

**Symmetric encryption** uses a single secret. The same secret sits on both sides; the sender
closes the body with it, the receiver opens it with the same secret. Its cost scales with the
body's length and carries no round cost — the secret is already on both sides. It has one
question: **how did the secret reach both sides?**

The question looks small but alone it makes the symmetric model unfit for a network protocol.
If the secret must be handed over in advance, every pair of endpoints that wants to talk needs
its own secret, and the number of secrets needed grows not with the number of endpoints but
with the number of **endpoint pairs**. Worse, two endpoints that have never met can never talk.
By definition, endpoints on a network do not know each other in advance; this is the first
problem transport security has to solve.

**Asymmetric encryption** uses a pair. The **public key** is distributed, the **private key**
is never sent. The pair works because of this: when one side combines its own private value
with the other side's public value, and the other side combines its own private value with the
first side's public value, the **same number** comes out. This number is never carried on the
wire; it is produced separately at both ends. This step is called **key exchange**, and the
number that comes out is called the **shared secret**.

The Identity, Access and Cryptography course established the security properties of these two
models: under what assumption something counts as unbreakable, what a signature proves, what
key length means. That theory is not repeated here. The difference sits in one sentence: there,
a **security property** was measured; here, **rounds and decisions** are measured.

## Order of Roles

The two models are not rivals; they run in sequence.

```text
setup — asymmetric roles
  1. client -> server : the client's public value
  2. server -> client : the server's public value
  3. both sides reach the shared secret on their own (nothing is carried on the wire)

transport — symmetric role
  4. client -> server : body closed with the shared secret
  5. server -> client : body closed with the shared secret
  6. 4-5 repeat as many times as needed; 1-3 do not repeat
```

This listing is not a specification summary; it is the order of the roles, and it is not run.
The line to notice is the sixth: **setup once, transport many times.**

## Mechanism Modeled Within the Lesson

The mechanism below is **modeled within the lesson.** It is not a real key exchange, not a real
cipher, and carries no security property: the modulus is shrunk, the private numbers are drawn
from a narrow range, the symmetric transform is a toy. The only thing modeled is **how the
sharing happens** and **which work happens at which step**.

```python
SEED = 20260809
PRIME = 2147483647          # modulus shrunk for the lesson
BASE = 5


def generator(seed: int):
    d = seed % 2147483646 + 1

    def r(n: int) -> int:
        nonlocal d
        d = (d * 48271) % PRIME
        return d % n
    return r


def public_value(private: int) -> int:
    return pow(BASE, private, PRIME)


def shared_secret(own_private: int, peer_public: int) -> int:
    return pow(peer_public, own_private, PRIME)


def symmetric(data: bytes, secret: int) -> bytes:
    r = generator(secret)
    return bytes(b ^ r(251) for b in data)


r = generator(SEED)
messages = [bytes(r(251) for _ in range(180 + r(441))) for _ in range(40)]

client_private, server_private = 1 + r(9973), 1 + r(9973)
client_secret = shared_secret(client_private, public_value(server_private))
server_secret = shared_secret(server_private, public_value(client_private))

carried = [symmetric(m, client_secret) for m in messages]
decrypted = [symmetric(c, server_secret) for c in carried]

print(f"shared secret matches: {client_secret == server_secret}")
print(f"messages {len(messages)}, body {sum(len(m) for m in messages)} bytes")
print(f"carried {sum(len(c) for c in carried)} bytes, decrypted identical: {decrypted == messages}")
```

```
shared secret matches: True
messages 40, body 15767 bytes
carried 15767 bytes, decrypted identical: True
```

Both sides used only their own private value and the other side's public value; the private
values were never sent, and both sides arrived at the same number. The body was folded with a
sequence derived from that number; applying the same sequence a second time brought the body
back. The carried byte count staying equal to the body is no coincidence either: the symmetric
role does not **lengthen** the data, it only makes it unreadable.

In a real transport every part of this mechanism is heavier: the modulus is chosen far larger,
the private numbers are drawn from a far wider range, and the symmetric transform not only
closes the body but also reports whether it was altered. The justification for these choices
belongs to the Identity, Access and Cryptography course. The only thing taken from the model
here is the **division of labor**.

## Setup Once, Transport Many Times

If all forty exchanges run through the same mechanism, the only thing that changes is **how
many times setup runs**. The measurement below counts this across three regimes: one setup per
message, setup split across eight connections, one persistent connection.

Assumptions of the measurement:

- **GT1** — The encryption itself is **modeled within the lesson**: the modulus is shrunk, the
  private numbers are drawn from a narrow range, and the symmetric transform is a fold. No real
  algorithm runs and the mechanism carries no security property.
- **GT2** — The body is produced from a single seed: forty messages, each between 180 and 620
  bytes. The same body is used in all three regimes.
- **GT3** — One setup counts as one round trip; at each setup both sides draw a **new** private
  value and two public values are produced.
- **GT4** — Three regimes are compared: setup per message, eight connections, one persistent
  connection. One setup runs per connection, and the secret from that setup covers the whole
  connection's exchanges.
- **GT5** — The smallest round difference measurable across a set of forty exchanges is one
  setup, i.e. 1/40 = 0.025; a gain smaller than this cannot be defended with this set.
- **GT6** — Role cost is measured not by time but by **operation count**: exponentiation is
  opened into square-and-multiply and modular multiplications are counted, folds are counted
  for the symmetric transform. What is counted in this lesson is rounds; the intermediary's
  decision table does not enter the measurement.

```python
SEED = 20260809
PRIME = 2147483647
BASE = 5
COUNTER = {"public_value": 0}


def generator(seed: int):
    d = seed % 2147483646 + 1

    def r(n: int) -> int:
        nonlocal d
        d = (d * 48271) % PRIME
        return d % n
    return r


def public_value(private: int) -> int:
    COUNTER["public_value"] += 1
    return pow(BASE, private, PRIME)


def setup(rk) -> int:
    client_private, server_private = 1 + rk(9973), 1 + rk(9973)
    public_value(client_private)                       # value sent to the server
    return pow(public_value(server_private), client_private, PRIME)


def symmetric(data: bytes, secret: int) -> bytes:
    r = generator(secret)
    return bytes(b ^ r(251) for b in data)


r = generator(SEED)
messages = [bytes(r(251) for _ in range(180 + r(441))) for _ in range(40)]

print(f"{'regime':<27s} {'setups':>8s} {'public values':>13s} {'symmetric bytes':>16s} "
      f"{'round trips per exchange':>25s} {'exchanges per secret':>21s}")
for name, connections in (("setup per message", 40), ("eight connections", 8),
                           ("one persistent connection", 1)):
    COUNTER["public_value"] = 0
    rk, total_bytes = generator(SEED + 1), 0
    secrets = [setup(rk) for _ in range(connections)]
    for idx, message in enumerate(messages):
        total_bytes += len(symmetric(message, secrets[idx % connections]))
    print(f"{name:<27s} {connections:8d} {COUNTER['public_value']:13d} {total_bytes:16d} "
          f"{connections / len(messages):25.3f} {len(messages) / connections:21.1f}")
```

```
regime                        setups public values  symmetric bytes  round trips per exchange  exchanges per secret
setup per message                 40            80            15767                     1.000                   1.0
eight connections                  8            16            15767                     0.200                   5.0
one persistent connection          1             2            15767                     0.025                  40.0
```

Across the three rows the **symmetric byte count does not change**: the body is the same in
every regime, 15767 bytes. The only thing that changes is the setup count and the round it
produces. In the regime that sets up on every message, each exchange carries **1.000** extra
round trip — every message pays for its own setup. In eight connections this becomes **0.200**,
in one persistent connection **0.025**.

The forty-fold difference comes from a single design decision. The asymmetric role's cost does
not scale with the body, it scales with the **setup count**; the symmetric role's cost does not
scale with the setup count, it scales with the **body**. This is the entire reason the two
models are used together: the expensive one runs rarely, the cheap one runs often.

This set's resolution is also read here. The smallest round difference measurable across a set
of forty exchanges is **one setup**, i.e. 1/40 = 0.025. A gain smaller than this cannot be
claimed.

The last column shows it is not free. As setups become rarer, **the number of exchanges tied to
one secret** grows: at setup per message one secret covers one exchange, at eight connections
five, at one persistent connection all forty at once. The round gain and isolation pull the
same lever in opposite directions. This is also why it matters that both sides draw a **new**
private value on every repetition of setup: a new private value re-bounds the scope to a single
connection. Reusing the private value — using the same one across every setup — costs nothing
in the table but silently pushes the last column to forty.

## Why Transport Is Symmetric

The order of roles is not a habit, it is the outcome of a calculation. In the modeled
mechanism, the cost of the two jobs can be counted in the same unit: the asymmetric role does
**modular multiplication**, the symmetric role does **folding**. Opening the exponentiation
into square-and-multiply and counting every multiplication shows where the order comes from.
The same body is carried by both roles and both round-trip back open.

```python
SEED = 20260809
PRIME = 2147483647
BASE = 5
PUBLIC_EXP = 1223
PRIVATE_EXP = pow(PUBLIC_EXP, -1, PRIME - 1)


def generator(seed: int):
    d = seed % 2147483646 + 1

    def r(n: int) -> int:
        nonlocal d
        d = (d * 48271) % PRIME
        return d % n
    return r


def power(base: int, exponent: int, counter: list[int]) -> int:
    """Square-and-multiply: every modular multiplication is added to the counter."""
    result, t = 1, base % PRIME
    while exponent > 0:
        if exponent % 2 == 1:
            result = (result * t) % PRIME
            counter[0] += 1
        t = (t * t) % PRIME
        counter[0] += 1
        exponent //= 2
    return result


def fold(data: bytes, secret: int, counter: list[int]) -> bytes:
    r = generator(secret)
    counter[0] += len(data)
    return bytes(b ^ r(251) for b in data)


r = generator(SEED)
body = b"".join(bytes(r(251) for _ in range(180 + r(441))) for _ in range(40))

setup = [0]
client_private, server_private = 1 + r(9973), 1 + r(9973)
server_public = power(BASE, server_private, setup)
power(BASE, client_private, setup)
secret = power(server_public, client_private, setup)

asymmetric = [0]
closed = [power(b, PUBLIC_EXP, asymmetric) for b in body]
opened = bytes(power(x, PRIVATE_EXP, asymmetric) for x in closed)

symmetric = [0]
s_opened = fold(fold(body, secret, symmetric), secret, symmetric)

print(f"{'work':<34s} {'modular multiplications':>24s} {'folds':>9s}")
print(f"{'setup (three exponentiations)':<34s} {setup[0]:24d} {0:9d}")
print(f"{'carrying the body asymmetrically':<34s} {asymmetric[0]:24d} {0:9d}")
print(f"{'carrying the body symmetrically':<34s} {0:24d} {symmetric[0]:9d}")
print()
print(f"body {len(body)} bytes, asymmetric round trip: {opened == body}, "
      f"symmetric round trip: {s_opened == body}")
print(f"asymmetric transport is {asymmetric[0] / setup[0]:.0f}x the setup")
```

```
work                                modular multiplications     folds
setup (three exponentiations)                            54         0
carrying the body asymmetrically                     930253         0
carrying the body symmetrically                           0     31534

body 15767 bytes, asymmetric round trip: True, symmetric round trip: True
asymmetric transport is 17227x the setup
```

Setup holds three exponentiations and does **54** modular multiplications total. However large
the body grows, this number does not change, because the asymmetric role touches not the body
but a **fixed-size number**. Carrying the same body end to end with the asymmetric role alone
costs **930253** modular multiplications: **17227 times** the setup. The symmetric role finishes
the same work in **31534** folds and does not perform a single modular multiplication.

What matters is not the size of the numbers but **what they depend on**. Setup's cost is
independent of the body; asymmetric transport's cost grows linearly with the body and runs
every byte through an expensive operation; symmetric transport's cost also grows linearly with
the body but the work it does per byte is cheap. The order of the roles comes out of comparing
these three curves: the expensive one runs where it can stay constant, the cheap one runs where
it can grow.

One more detail remains. The shared secret coming out of setup is not applied directly to the
body; a separate **session key** derived from it is used, and usually a separate key is derived
for each direction. Derivation is a local operation, it sends no message and adds no round.
What it gains is the separation of the two directions and the two sessions from each other. In
terms of the round budget, the result is this: even as the number of derived keys grows, **the
setup's round stays fixed.**

## What the Intermediary Sees and Does Not

Once the body is closed, the intermediary cannot take a decision from the body. The transform
this lesson modeled closed only the body; the **transport envelope**, the subject of the next
two lessons, also folds in the header fields and narrows the set the intermediary can read even
further. What that narrowing does to the decision table is counted in the handshake lesson.
What is counted here is only the round.

In a closed transport there are things the intermediary still sees, and each has a narrowing:

- **Host name.** The intermediary needs a name to route the message to the right server.
  *Narrowing:* if the name is also folded into the envelope, the intermediary cannot route; this
  is not a loss but a trade — the decision is taken from the intermediary, given to the
  endpoint.
- **Message length.** Since the symmetric role does not lengthen the data, the bytes carried are
  the body's bytes. *Narrowing:* if padding is added to the body, length becomes a coarse band
  and its distinctiveness drops.
- **Round pattern.** How many messages go, at what interval. *Narrowing:* if the exchanges are
  merged into a single persistent connection, the pattern is buried inside one connection.
- **Destination address and port.** The transport layer's headers stay outside the closed body;
  the intermediary must read them to be able to forward the message. *Narrowing:* if many
  endpoints are gathered behind the same address, the address points to a set rather than a
  single endpoint and its distinctiveness drops.

There is also a choice that must be read as a **configuration flaw**: skipping the setup round
entirely and binding the shared secret to a fixed, pre-shared value. The gain is obvious — even
the 0.025 row disappears from the table. What is wrong is this: every session binds to the same
secret, sessions cannot be separated from each other, and a single leak of the secret also
covers past exchanges. What this lesson states is what is wrong; how this is used is not this
course's subject.

## Summary

- The symmetric model uses a single secret and its cost scales with the body; the asymmetric
  model uses a pair and its cost scales with the setup count.
- Key exchange is two sides arriving at the same shared secret without sending their private
  values; the shared secret is never carried on the wire.
- In the mechanism modeled within the lesson, both sides arrived at the same number and the
  15767-byte body round-tripped through the transform identically; the mechanism carries no
  security property.
- Across forty exchanges, extra round trip per exchange comes out to 1.000 at setup per
  message, 0.200 at eight connections, 0.025 at one persistent connection — the symmetric byte
  count is the same in all three.
- In closed transport, host name, message length, and round pattern each have a narrowing;
  binding the shared secret to a fixed value is a configuration flaw.

## Next Step

Setup produced a number, and both sides agreed on that number. But what was agreed is the
**secret, not the identity**: the shared secret says nothing about who the other end is. The
side that sent the public value could be the measurement station, or it could be some other
endpoint using that name. The only thing in the client's hand is still what the message says —
and nothing verifies what the message says. The next lesson measures how a name binds to a
public key, and how many times the client asks a question while verifying that bond.
