0%

Chapter 4 · practice

Masks and Control Frames

Mask and Unmask a Payload

Your learning codec reads every a server sends. It reads no frame a client sends, because RFC 6455 requires a client to every frame, and Chapter 3’s decoder refuses a masked one. Section 5.7 of the RFC prints “Hello” both ways, unmasked as a server sends it and masked as a client sends it:

81 05 48 65 6c 6c 6f
81 85 37 fa 21 3d 7f 9f 4d 51 58

The first byte is the same, 81: FIN set, 1, text. The second byte is 05 in one and 85 in the other: the same length 5, plus the MASK bit 0x80 you read in Chapter 3. Then come four bytes the unmasked frame does not have, 37 fa 21 3d. That is the masking key. Last come five payload bytes, and in the masked frame they no longer spell “Hello”. They are “Hello” combined with the key, byte by byte. This lesson writes that combination; the next one puts it into frames.

XOR with a rotating key

RFC 6455 section 5.3 defines masking in one rule: byte i of the masked payload is byte i of the payload XOR byte i % 4 of the key. The key has four bytes, and i % 4 walks through them in turn, 0, 1, 2, 3, then back to 0, for as long as the payload lasts. Here is “Hello” with the RFC’s key:

iPayload byteKey byte (i % 4)XOR
048 (H)37 (key[0])7f
165 (e)fa (key[1])9f
26c (l)21 (key[2])4d
36c (l)3d (key[3])51
46f (o)37 (key[0])58

Position 4 wraps around and reuses key[0], the same key byte as the H. The two ls at positions 2 and 3 give 4d and 51, because their positions use different key bytes, so equal letters do not give equal masked bytes. a bytes gives an int (Networks with Python Chapter 2), so payload[i] ^ key[i % 4] is a number from 0 to 255, and a of those numbers becomes bytes again with bytes(...).

With a key whose four bytes are all ff, every key byte is the same, so the position does not matter, and XOR with ff flips all eight bits of each byte. Predict two such flips first:

What does this print?

print(hex(0x71 ^ 0xFF), hex(0x8E ^ 0xFF))

This block masks the four bytes of b"quiz" that way:

Try it

The second flips the same bits back and prints b'quiz'. A real key has four different bytes, so your needs the position i to pick the right one.

Which key byte masks payload byte 9?

Its own inverse

Chapter 3’s first lesson asked what (0x48 ^ 0x37) ^ 0x37 prints. The answer was 72, which is 0x48, the H of “Hello” masked with key[0] and unmasked again. XOR with the same value twice always gives back what you started with: x ^ k ^ k == x. So one function does both jobs. The client calls it with the key to mask a payload; the server reads the key from the frame and calls the same function with the same key to get the payload back.

Why clients mask

Masking is not there to protect the message. It protects the network between the client and the server. RFC 6455 section 10.3 explains the worry: a proxy or cache on the path that does not understand might read the bytes after the as if they were . If a script running in a browser could choose those bytes, it could write something that looks like an HTTP request and response, and a confused cache might store an answer the attacker made up and serve it to other people. The RFC calls this cache poisoning.

A fresh, unpredictable key for every frame takes that choice away. The script picks the payload, but the browser picks the key, so the script cannot know which bytes will cross the network. That is why the rule applies to clients: a browser runs code from websites it does not trust, and a server does not.

Masking hides nothing from anyone who can read the frame, because the key travels in the same frame, four bytes before the payload. Anyone who sees the frame can unmask it, exactly as your server will. Keeping a conversation private is the job of , the wss:// scheme you meet in Chapter 9.

Why is masking not encryption?

Your task

This chapter starts from the course’s own finished Chapter 3 files, whatever your Chapter 3 code looked like. ws_frames.py is the Chapter 3 codec with this chapter’s new functions added as stubs; each docstring names the lesson that writes it, and the Chapter 3 functions say “Chapter 3 wrote it.” ws_handshake.py is the course’s finished handshake from Chapter 2, and tests/test_frames.py is the course’s version of the suite from Chapter 3 lesson 6, with its @pytest.mark.parametrize decorators. Both are there to read; no lesson here edits them. These lessons have an editor and no , so no step asks you to run the suite, but if you download the project to your own machine, python -m pytest in its folder runs it. Its 15 tests cover unmasked frames, and every change in this chapter keeps all 15 passing.

In ws_frames.py, write apply_mask(payload, key). Loop over the positions of payload with range(len(payload)), XOR byte i with key[i % 4], and return the results as bytes. The same function must also unmask.

Press Run. Run starts frames_demo.py again, now with one section per lesson of this chapter; its last lines run them only when the file is started as a program, behind the if __name__ == "__main__": guard. With apply_mask written, the output begins:

Lesson 1: Hello masked with 37 fa 21 3d
7f 9f 4d 51 58
Lesson 2: a masked text frame
  not written yet

Then press Submit. The check masks the RFC’s “Hello” and masks the result again, then does the same with keys and payloads you have not seen: a 7-byte text, an empty payload, and 65,536 bytes. It expects bytes back every time. In the next lesson, encode_frame and decode_frame use your function to build and read masked frames.

Task

Write apply_mask(payload, key) in ws_frames.py. Byte i of the result is byte i of payload XOR byte i % 4 of the 4-byte key. over the positions with range(len(payload)), use ^ and % 4, and return the result as bytes. Called again with the same key on its own result, the gives the original payload back.

Press Run: the Lesson 1 section of frames_demo.py should print 7f 9f 4d 51 58. Then press Submit. The check the RFC’s “Hello” with 37 fa 21 3d and masks the result again, then does the same for keys and payloads you have not seen: a 7-byte text, an empty payload, and 65,536 bytes. It expects bytes of the payload’s length every time. Leave the other functions in ws_frames.py as they are.