Skip to content
academia.sh

Lesson 07 / 16

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.

Contents

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.

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.

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

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close