#Tagliamonte
Ciao, amanti dei gioielli! Hello, jewelry lovers! One of the best aspects of jewelry is how each piece means something unique to its wearer, but it also can speak to anyone. For a piece that speaks to you, try our TagliamontE 925SS/YGP Venetian Cameo Ring.
www.tagliamontejewelry.com/store/rings/...
September 30, 2026 at 8:29 PM
Do you dream of adding TagliamontE jewelry to your collection? We think these whimsical TagliamontE Luna 925SS/YGP Venetian Cameo Earrings are the perfect selection for making that dream come true!
www.tagliamontejewelry.com/store/earrin...
September 25, 2026 at 8:31 PM
How we communicate is one of the most uniquely beautiful parts of humanity. This International Day of Sign Languages, show love to those who emote through their hands with our TagliamontE 14K Venetian Intaglio Ring featuring the ambassador of love, Cupid.
www.tagliamontejewelry.com/store/rings/...
September 23, 2026 at 7:34 PM
Like the olive branch, these TagliamontE Busy Bee 925SS/YGP Venetian Intaglio Earrings come in a beautiful olive-green color with the carving of a bee, sharing the message of peace and community for today's International Day of Peace. www.tagliamontejewelry.com/store/earrin...
September 21, 2026 at 8:27 PM
Paul Tagliamonte: Why Write?
As a graduate of a liberal arts university, I wound up, unsurprisingly, taking a lot of classes in every possible academic discipline. Thinking back to the person that I was going into university, I don’t think I would have chosen to take them – after all, my degree was in the sciences; I’d have been stoked to do nothing more than wall-to-wall computer science until I ran out of coursework and filled the rest of my hours with research and independent studies with my professors (which, I guess I did actually do, just not as much as I would have otherwise). Even this blog’s CSS theme (now 18 years old and starting to look it) was something I wrote after receiving and repeatedly re-reading a tattered third-hand copy of “The Laws of Thought” by George Boole. It was gifted to me by a college friend majoring in philosophy while I was crashing at his rental on the beach. He gave it to me because he knew I “liked that shit” and, while it was definitely computer science in nature, I would not have read it otherwise. I have vivid memories of sleeping on his la-z-boy surrounded by towers of books he was working through. He went on to do incredible work, getting his PhD, doing research, brilliant writing – his death in 2023 has robbed humanity of more time with him. “The Laws of Thought” sits behind me in my office, and is one of my most valued possessions. My friends mean more to me than I could ever express, and I definitely don’t show it enough. Each of my classes made me a more thoughtful person. My professors made me a better, more well-rounded person – and a person who attempts to live up to the oft repeated credo of being “men and women for others”. I did the best I could, even though I was never a particularly good student. Without liberal arts, I don’t think I would, dispositionally, have been capable of pushing myself – to this day – to continue to learn on nights and weekends, for no reason other than wanting to learn. I _refuse_ to stop expanding my perspective, and do my best to approach new problems with as much humility and curiosity as I can muster. Every few days for the last year or so, I have been thinking back to a reading assignment from my junior year that, at the time, I thought was a borderline throwaway filler assignment for the class. The reading is an essay from 1948 by the french existentialist philosopher and libertarian marxist, Jean-Paul Sartre (as some of the more erudite may have now already worked out given the blog’s title), “Why Write?”. I re-read it this week. It is not filler. I remember this essay better than some of the classwork I considered more important at the time. # Why Write? “Why Write?” starts off by describing the ways in which writing – trying to communicate your thoughts, opinions, or feelings to others – is an act of _projection_. The writer will only ever draw from a place of their “own subjectivity” – writing is taking your person and putting it on display. You’re choosing what words to use, how to use them, pulling from knowledge you’ve accumulated (based on how you’ve chosen to spend your time). Spending any amount of your finite existence in order to convey a thought is, itself, announcing to the world that you believe it to be a thought worth sharing. Meanwhile, the act of reading is _not simply_ turning letters into words. Reading is engaging with the work, and _understanding_ the work in a process that looks more like what Sartre terms “re-invention” or “discovery” – “the literary object, though realized _through_ language, is never given _in_ language”. It is not enough to read the words in a book end-to-end; you must, as a reader, actively engage with the work to understand what is being communicated by those words. Reading is to take the words on the page, and “exceed” the mere words through what he calls “directed creation”. The reader is “re-inventing”/“discovering” the writer’s thoughts by following their “landmarks in the void”. And this all makes pretty good sense to me – I know if someone is being sarcastic because I know the writer; I have read their words and have _read_ into their words, allowing myself to be directed by the writer into “discovering” the thought they have left me in their work. I understand that their words are _humorous_ , not _irate_. Knowing who the author of a work is can completely change the point of a sentence. The fact I’m writing about Sartre at all, or that this very writing has been constrained for presentation in this blog’s CSS is only happening because of who I am, what things I’ve experienced in life, and what has made me, me. Sartre argues that this dyadic coupling between writer and writing means that I, as a writer, can never truly _read_ my own writing. I will never be capable of _reading_ my own work and getting something out of it – the work is already an extension of my own person. I can discover no thought in my own works. Of course, Sartre, as an existentialist, is honor bound to go one step further here – the reader, by participating in _reading_ a work, is asserting their own freedom. Every time a writer writes “[…] the writer appeals to the reader’s freedom to collaborate in the production of [their] work”. The creative act may only be complete if both writer and reader have recognized one another and made a choice to do so. No one is _compelled_ to complete the creative act. The things you _read_ matter. Reading something fundamentally alters you as a person – you can not simply un-read a thought. Everything you read changes your universe. I have a hunch Sartre would find things like the (original early 2010s era) “tl;dr” reply to obviously terrible writing absolutely hilarious as a reader’s expression of freedom (not to be confused with modern 2020s era usage as shorthand for “summary section”). Using your freedom to choose to complete the writer’s creative act (or not) is inherently asserting your humanity. I’m a bit fuzzy on the specifics of this quote, but I think it was sj who told me at one year’s Mystery Hunt that “A puzzle is a contract between puzzle author and puzzle solver, the author promises that the puzzle is solvable if you’re clever enough”. The puzzle author and puzzle solver are engaging in a collaborative production of the work that is only possible by recognizing one another. Similarly, Sartre – “whatever connections [the reader] may establish among the different parts of the book among the chapters or the words [the reader] has a guarantee, namely, that they have been expressly willed”. # Wall Drawing #123 It follows, Sartre argues, that the act of writing, as a means to convey a thought to a reader, may only be completed through the act of _reading_. _Reading_ can only be done by others; writing, therefore, only exists to be _read_ – it can serve no other purpose. Writing and reading are two halves of the same creative, collaborative act, only satisfied when both writer and reader acknowledge one another. Combined, writing and reading are the act of recognizing one another’s humanity, thoughts, experiences, consciousness – the act of co-creation of thought is the critical aspect of writing and reading – reconstructing the author’s perspective, thought, intent, point. The co-creation _is the point_ of both _reading_ and _writing_. Writing without anyone to _read_ the work leaves the writer’s bid for co-creation unsatisfied and humanity unrecognized. No thought has been conveyed to any reader, no one has understood the reason behind the “landmarks in the void” you’ve carefully placed, leaving you to either “[…] put down [your] pen or despair”. Writing is an appeal to the reader’s freedom, and the nature of that freedom is the writer can not control it – never being understood is always a possibility any time anyone sets out to write. Nearly 12 years ago, I attempted to read the text on the git manpage generator for the first time – I remember the **exact** feeling of my brain going into “git manpage parsing mode” where every word was dragging git plumbing megaliths through the sand from their far-flung homes. I can still feel the surface of my desk as I instinctually started to trace out logical connections between git internals referenced as I read along. It took me a good 20 seconds to realize what I was reading made no sense. This website broke Sartre’s writer-reader agreement – I was attempting to _read_ this website, but there was nothing there. You can “mechanically” “read” the `git-man-page-generator`, but you can never _read_ it – it is not possible to _read_ it. It was funny – unsettling. Each grasp at a real-looking image in front of me misses, my hand coming back empty. I couldn’t get enough of it. I must have tried to read a dozen generations in a row. I had never had something quite that broken pass that far into my consciousness before. Although I kept trying, it never did feel quite the same as that first time; I think fundamentally, I couldn’t forget that it was random. While never having anyone _read_ your _writing_ drives them to “put down your pen or despair”, Sartre never had to contend with the opposite problem – being asked to _read_ that which was not _written_. _Reading_ that which was not _written_ does not merely leave someone unrecognized when a reader makes a choice; instead, it is an **inherent violation of the contract between reader and writer**. Not only is no humanity being recognized through attempting to _read_ words which were not _written_ , but the _reader_ , not the writer, is the one who bears the burden of unexpected apophenia. The _reader_ , while engaging in co-creation, must now contort themselves to uphold their end of the Sartrean agreement, scrutinizing text to ascribe consciousness, thought and intent only to come back up with pareidolia-fueled echos of one’s own self and disfigured half-thoughts of others. The reality is, the modern reader must now “mechanically” “read” most written work they come across, scrutinizing text for any signs of thought before truly attempting to _read_ the work. Failing to do this correctly changes our person – each time it happens, a small part of us is irrevocably altered. Choosing to engage in this ballet because you wish to do so is one thing – this is your right and freedom as a reader – but passing words which were not _written_ as your own in an attempt to wrestle a reader’s freedom of co-creation from them is another entirely. **If you didn’t write it, I don’t want to read it (dw;dr). Send me your prompt instead.**
notes.pault.ag
September 18, 2026 at 5:25 PM
If you’re back to work from the summer, you probably can relate to Hercules and his twelve labors. Our TagliamontE 14K YG Cameo & Citrine Earrings depict one of those labors with the Stymphalian birds.
www.tagliamontejewelry.com/store/earrin...
September 16, 2026 at 11:48 PM
Paul Tagliamonte: DESFire EV3
I’ve _long_ been interested in hardware key material storage devices. I’ve been a fan of yubikeys (I still remember when my fancy new NEO-N showed up), PIV (and its associated smattering of additional fields), SaaS HSMs, the kernel keyring, some tooling I’ve fairly satisfied with the design of at prior companies, and of course, our dear friend, the TPM. All that is not even to mention the scores of exotic hardware security modules one generally comes across from time to time when you’re keeping a sharp eye out that you wind up playing with. I have not used any LLMs in the course of this adventure. Not for writing these posts, and not for this code. The intent here was to learn more about how DESFire works. LLMs defeat that purpose. The concept of storing private key material on a disk, or even having it in RAM has always skeeved me out, so I have a natural inclination to hardware modules, and how shifting keying material around can change your risks and threat model(s) in interesting ways. I don’t remember when I first came across the MIFARE DESFire EV3, but a few weeks ago I did a deep-dive into the state of the art of authentication schemes using ID cards. My complete overview of what tradeoffs exist is pretty extensive (and likely not interesting to the vast majority of the world), but the tl;dr wound up being one of “use PIV” or “use MIFARE DESFire EV3”. I wound up picking DESFire for a recent project, and figured it’s worth talking a bit about what I learned, share some thoughts, and some code. That code is published on crates.io/desox, and docs, as is our custom, may be found at docs.rs/desox PIV, while oft-maligned, is exceptional for public key cryptography using asymmetric keys, and can safely interoperate with x.509. If any of those things are a hard must, I don't think that's going anywhere. DESFire supports DES (I’m sure most readers saw that one coming), 3DES (I didn’t bother playing with 3DES at all) or AES-128 (AFAICT always use this?) keying material. It’s worth noting that the DESFire only supports symmetric keys and is not designed for public key cryptography, and operates exclusively using shared symmetric key material. The DESFire EV series use those keys and related authentication schemes to interact with “files” stored on the on-chip EEPROM (2k, 4k, 8k, and 16k versions exist), or “applications” (groups of files and authentication keys). # Talking to a DESFire EV3 Interactions with the card are done over NFC (ISO/IEC 14443 Type A), and commands to/from the card may be in the usual ISO/IEC 7816-4 APDU format, or “unencapsulated” bytes sent to/from the card are sent using a fixed instruction set and return code structure – saving a few bytes per message. I’ve opted to use their undocumented and proprietary format – I found it easier to work with and with a maximum message of 60 bytes, the savings matter a lot. I keep calling the DESFire messages I implemented "APDU messages" since I have to use a bunch of API surface saying it is -- but they're not. While powered via NFC, the card maintains a small amount of state about the connection between the reader and the card in its RAM, including if the session is authenticated or unauthenticated. I’ll dig into how authentication happens later, but it’s worth knowing that sessions _can_ become authenticated using one of the symmetric keys shared by the card and the reader. The vast majority of the DESFire commands I know about tend to work while either authenticated or unauthenticated, with a few exceptions (`GetUid`, `ChangeKey`, and `ChangeKeySettings` for example). In general, I found working with this card particularly pleasant. There is a fair amount of backwards-compatible behavior and multiple methods of communication that confuse things a bit, but overall, it was better than average to integrate with. Kudos to the NXP team. If the docs on this chip were public, things would be orders of magnitude easier – it’s not entirely clear to my why they’re keeping so much of the interface documentation under NDA, but it’s the largest knock against the chip, by far. ## Authentication I found a lot of really great resources outlining how the handshake and protocol works for a DESFire EV3, especially from Ridrix, some public datasheets ThrRealRevK and posts from AndroidCrypto. It's not super clear to me why all of this is under such heavy NDA, surely a robust ecosystem is nothing but good? The gist here is that, because the DESFire only does symmetric key operations, the key exchange (a type of SKA – Symmetric Key Agreement) uses symmetric keys to establish a unique session key which is used to sign or encrypt data exchanged between the reader and the card. I’m not going to get too in-depth here, since there’s a ton of other resources out there to dig into – but I will do a quick high-level description to keep this post mostly self-contained. The authentication protocol serves two main functions – to verify that both parties know the same shared secret, as well as to act as a SKA to construct a new session shared secret key. Here’s a quick overview of how a shared session key is derived between the reader and the card using our symmetric keys (`AES-128` in the case below). 1. the reader requests to start authentication with the card (something like `AA 00` to start an AES Authentication handshake with keyslot `0x00`). 2. The card will then reply with `AF` (a status code that indicates more data is to follow), followed by 16 bytes (in the case of `AES-128`) of encrypted (using CBC) data. 3. The hosts then decrypts this block with the symmetric key from keyslot 0, returning the card’s session nonce. 4. The host generates 16 bytes (usually random) for its session nonce. 5. The host sends an instruction of `AF` (indicating a continuation of the previous command), followed by 32 bytes of encrypted data. When decrypted, the first 16 bytes are our nonce generated in step #4, followed by the 16 bytes provided by the card, decrypted in step #3, except where every byte is shifted to the left by one place (the 0th byte is copied to the end). 6. The card will reply with `00` indicating a successful operation, followed by 16 bytes, which when decrypted, is our session nonce from step #4, shifted to the left by one byte in the same way that we did in step #5 with the card’s nonce. 7. At this point, both the reader and card have confirmed the other party has the same symmetric secret key. The session is now “authenticated” and a “session key” is derived using the two nonce blocks. Two hashing keys (K1 and K2) are derived from this key, which is used to maintain an ongoing CMAC hash of the messages coming and going to/from the card. From here on out, the session is “authenticated”, and responses from the card which were previously “plain” will now contain a 8-byte CMAC signature, which can be used to ensure that the replies in question come from the active session. In my implementation of the handshake I opted to encode the handshake state into rust types, just so I wouldn’t make any mistakes. The `Handshake` type contains the session internals (session nonce values, keying state, to include IV, etc). This means the authentication flow (from within my code) uses the `Handshake` struct to generate the commands to send to the card in order: /// Create a new `Handshake`, and return the /// start auth command (something like `AA 00`) fn Handshake::<Initial>::begin( output: &mut [u8], key: [u8; 16], key_id: u8, ) -> (Self, &[u8]); After we get a reply back from the card (the encrypted version of the card’s session nonce, sometimes called `Rnd_B` in code I’ve seen), we transition states from `Initial` into `HalfOpen`. /// Given the card's encrypted response, generate /// our session nonce and generate a reply /// (something that starts with `AF` followed by /// 32 bytes of encrypted data). fn Handshake::<Initial>::rnd_b( self, output: &mut [u8], input: &[u8] ) -> (Handshake::<HalfOpen>, &[u8]); Now that we’re “`HalfOpen`”, we’re waiting to hear back from the card to ensure that it, too, can byte-shift our provided nonce. Once we have the card’s reply, we can check it using our `complete` helper, transitioning from `HalfOpen` to `Successful`. /// Check to ensure that the card replied with /// our nonce byte-shifted by one place, indicating /// that they know the symmetric secret in /// this key slot. fn Handshake::<HalfOpen>::complete( self, input: &[u8] ) -> Handshake::<Successful>; Once the `Handshake` is successful, the only thing left to do is consume the `Handshake` struct and turn it into the shared session key by running it through the key derivation function. /// Consume the `Handshake` struct and return the /// new shared session secret key. fn Handshake::<Successful>::into_key(self) -> [u8; 16]; From here on out we can use this session key for the remainder of our interactions with the card – signing messages from (and sometimes to!) the card, or encrypted messages to and from the card. This key is used in CBC block mode, where the session IV is updated with the last block of the encrypted data. ## Unit Testing A nice proprietary of the SKA scheme we’re using as part of DESFire is that the derived session key is actually deterministic if you control your nonce RNG (ok, actually, pretty true for most key agreements, but anyway), which means it is possible to capture traffic over the NFC interface, and “replay” the NFC I/O with cooked RNGs and ensure byte-identical messages and keys are generated. Within `desox-rs` this is called `replay` (I’m creative), and I’ve got a few replay sessions checked into VCS, which exercise a signficant amount fo the API surface. All were derived from an actual session with a real DESFire card, and can be updated with a live card and a `--cfg` flag. This replay stuff wound up being super dope, it caught a ton of almost-regressions during the heavy development phases. If I did this again from scratch -- this would be the first thing I did. Each `replay` file is a set of lines (request-response transactions), each containing two space-delimited hex encoded NFC messages. For instance, here’s an authentication handshake in `replay` format: 1a00 afc7bbd82ff8fefae8 afc6dab54df2278d2952d560821be7e4c3 007d9abe94a9b14748 The code that generated that exchange came from the test stored adjacent to that file – a handshake with the default DES key (all zeros), and an `RndA` value hardcoded to `32c28fdafd3960de`. let mut card = card .authenticate_with_rnd_a( 0x00, Key::Des([0; 8]), Key::Des(hex_literal::hex!("32 c2 8f da fd 39 60 de")), ) .await .unwrap(); Since the card’s `RndB` is similarly unchanging (I’m replaying this file every time), this will always derive the same session key, which means messages (including encrypted ones or CMAC signed responses) will be identical, as well. If you’re playing with the DESFire yourself, feel free to grab my replay files if you need a “known good” baseline. By default this will run using the MockBackend, replaying each file – expecting a byte-identical request, and responding with the harcoded customary reply. If the code (or test!) needs to change, updating the tests is done by swapping the `MockBackend` out for a real one. Since I had to do this a bunch during development, running `cargo test` with `RUSTFLAGS="--cfg desox_replay_rw"` will, on run, overwrite the replay file(s) for the executed test(s), ensuring all line-protocol changes are explicitly caught and reviewed. # Observations Most commands, even ones which require authentication, are transmitted without CMAC signature(s) or encryption. `CMAC` signatures from the reader to the card are not really used (except for writes to a file which specifies communication must be `CMAC` signed), ditto for encryption (although that one is used for key change operations, in addition to file writes on files that specify encrypted communication must be used). The vast majority of commands take a “plain” request from the reader, and return a CMAC signed response. I really wish there was a mode or configuration flag I could flip that would enforce CMAC signatures from the reader to the card. By my eye, this means that a malicious reader, or something otherwise capable of holding the card online after communication with an authentic reader is complete are able to execute privilaged commands (since one can simply ignore the `CMAC` signatures on responses), so long as the command doesn’t require the reader to provide CMAC signatures (or encryption), or allow the card to power down. # Fun with DESFire I’ve played around a bit with ways to use the DESFire cards in interesting configurations, given what they’re capable of. Here’s some half-baked thoughts I had while mucking around with the cards – these are all poorly thought out sketches of some things we can do given the specific tradeoffs I see with the DESFire card. It’s also worth noting that I don’t have any of the actual documentation, and am not a cryptographic grown-up, so take these sketches with a massive grain of salt. This stuff is right around when I really miss having asymmetric cryptographic operations handy. The first thing that came to mind when implementing this is how the authentication scheme can shift the boundary of what is and is not trusted (assuming good secure keying, and provided the key slots and card/application permissions are configured correctly). Rather than push the key material out to the machine connected to the NFC reader (“reader machine”), I instead tried turning the NFC reader and computer into something psuedo-untrusted by “merely” having it pass messages from the card to a trusted remote system (“remote machine”). This means that the “reader machine” is exchanging NFC data with the card, but that data is being decrypted, encrypted and processed by the trusted “remote machine” – the reader is unable to derive the session key. For each of these, I wind up needing to authenticate – so there’s still a few latent risks, but these can mostly be mitigated by asking for a readbacks of any changed file(s), setting key permissions carefully, and requesting the card’s UID via the encrypted channel – all of which would require the symmetric secrets (which undermine the whole security model if comprimised). This all feels a bit messy at times -- but I have to keep grounding myself in the threat model -- "if you have the key, you can clone the card (or snoop the session key)" This general construction is also subject to a hostile takeover of the untrusted “reader machine”, since most commands (including destructive ones!) are sent in “`PLAIN`” mode – the reader machine can wait until authentication is complete and then inject commands into the card and “simply” ignore the CMAC signatures on responses, severing ties with the remote machine. As such, we also need to take steps to ensure that the key being used is not one that allows any access beyond what is allowed. Here were some ideas I sketched out off the back of this theory. ## The “second-factor” Given some established (and authenticated) connection, part of the initial authentication flow may use the DESFire card to prove physical control over it as part of a handshake. This can serve as a second factor during some authentication flow, requiring physical card presence at a reader to fully initialize a connection. This does have one glaring downside, however – it’s phishable. To use this “for real”, we’d need to take some steps to prevent obvious MITM flows (XOR the NFC messages with the URI as seen by the client?), but maybe there’s something interesting there. WebAuthN is objectively better in basically every way to this -- this scheme has some heafty downsides, but also a few interesting properties. This also has a second interesting attribute – when used as part of a physical system authentication flow, this becomes a logical place to inject access control, being able to determine if some person is permitted to operate some device at that particular time (Is “Joe” current on his Laser Cutter certifications?) I think of the ideas I landed on, while conceptually interesting (using an employee id card as a 2FA token, it’s very fast), this one is the least likely to turn into something real. ## The “encrypted-cookie” This construction, when paired with an encrypted DESFire file, allows the “remote machine” to read/write an ’encrypted cookie’ to the card – storing small amount of encrypted data that the “remote machine” can read/write, but not the “reader machine”, since this uses an encrypted and authenticated channel from the “remote machine” directly to the DESFire card, without any intermediate hosts needing to be _fully_ trusted. I keep calling this the “encrypted cookie” in my head because it feels conceptually similar to how Ruby on Rails and Laravel handles cookies. I never really liked encrypted cookies. We’d need to take a few extra steps here (for instance, ensure that you read the cookie back over the encrypted channel after writing to prevent a malicious reader from dropping writes) to secure the system, but it feels like the structure of this is definitely decent. ## The “takeover” This time, let’s say the computer attached to the NFC reader (“reader machine”) _is_ semi-trusted. For this scheme, our trusted “remote machine” and the “reader machine” pass messages over the network to handle authentication to the card (as above), where the handshake data is being decrypted, encrypted and processed by the trusted “remote machine” as usual. However, once the authentication handshake is complete and a session key has been derived, the “remote system” return the session key to the “reader machine”, giving it a one-time-use key and authenticated session to the card. Like a hermit crab. We need to be careful about global/application permissions and key access control to files – but in this construction, we can allow the “reader machine” to take over privileged actions using a scope-limited DESFire key without handing over the card’s true keying material (preventing cloning of the card). This can be helpful to ensure messages to/from the card are _truely_ from the card (verifying CMAC signatures), enables the “reader machine” to directly read/write to/from encrypted file(s), but allows the symmetric key material to remain in as few places as possible – which is critical given compromising that secret will undermine the security of the entire system.
notes.pault.ag
September 15, 2026 at 3:22 PM
Did you know the origins of cameo jewelry date back to the ancient world? In our opinion, cameos also add the perfect vintage touch to a modern-day outfit, like with this TagliamontE 18K Cameo Pendant that features a beautiful cut-work flower.
www.tagliamontejewelry.com/store/pendan...
September 9, 2026 at 8:45 PM
Paul Tagliamonte: wrapping it all up (Part 8/12) 🕊️
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series. **If you're looking for it, the intro to the PHY (layer 1) that this belongs at a high level and listing of the parts that make it up (including this one) is on the phy post**. The time has come. If you’re following along at home, we now have all the basics we need to glue these parts together and see what this looks like. We’re going to build the highest-level constructs for the PHY in code – something that takes some number of bytes in and writes out IQ samples fit for transmit over the airwaves (we’ll call this the `Encoder`), and something that takes chunks of IQ samples in, writing out decoded bytes (which we’ll call the `Decoder`). # Encoder Let’s begin with the `Encoder`, since it’s slightly less involved. I’ve tried to make this a bit more accessible by drawing a diagram out before describing the order of operations, so that it’s possible to follow along visually. While the process here can look like a lot, it’s really not that bad. We begin by taking the incoming bytes, converting the bytes into bits, and chunk those bits into parts which are sized to fit completely within an LDPC message. We will then encode incoming data into LDPC messages, using our configured LDPC Matrix (the table we appropriated from 802.3an). Next, we apply whitning over all the bits in our encoded (and packed) LDPC messages, using our configured whitening constant. In the case of QPSK, pairs of bits will then be modulated into a QAM subcarrier, where each QAM point represents a range of bits in the message. We’ll go through each of those modulated IQ subcarriers, and set each corresponding data subcarrier in order, for each OFDM symbol contained in the pigeon Burst. The preamble configuration is then used to generate (or, more likely, can be used at startup to precompute) the Schmidl-Cox preamble, which is written to the first IQ samples in our output IQ buffer. Finally, we will do a series of inverse FFT operations to convert each OFDM symbol to the time domain, including their cyclic prefix. Let’s take a look at doing that, but in code this time now: // (lightly edited for clarity) impl Encoder { .. /// Encode the provided bits into the output time-domain IQ samples. fn encode( &mut self, dst: &mut [IQ], src: &Vector, ) -> Result<Burst, Error> { let src = { let mut raw = Vector::new(self.fec.message_len()); // Set `raw`'s data bits, compute and set LDPC // checkbits. self.fec.add(&mut raw, src); // Apply whitening, and return raw.xor(&self.whitening) }; // copy in the precomputed schmidl-cox preamble to `dst` let preamble_len = self.preamble_iq.len(); dst[..preamble_len].copy_from_slice(&self.preamble_iq); // allocate a new (frequency domain) 'Burst' container. let mut burst = Burst::new( &self.mode.ofdm.plan, self.mode.ofdm.symbols ); // modulate bits from 'src' as iq, and set each // data subcarrier for each ofdm symbol in the // burst. self.burst_encoder.multiplex(&mut burst, &src); // convert from frequency-domain data into time // domain iq samples, writing out ofdm symbols // and cyclic prefixes to `dst`. self.burst_encoder .transform(&mut dst[preamble_len..], &burst)?; // normalize all IQ samples; the maximum magnitude // in the IQ buffer may be very small, which weakens // our transmitted signal. Scale all IQ samples such // that the maximum IQ sample magnitude will be '1.0'. dst.norm(); Ok(burst) } } Using the `Encoder` should hopefully be fairly straightforward – we’ll give it a bag of bytes, and get back some IQ samples that we can ask our nearest SDR to transmit. As for what happens on the other end? # Decoder Next up is the mirror image of our `Encoder` – the, imaginatively named, `Decoder`. The `Decoder` is slightly more involved (since it has to find the packet in the IQ stream, as well as correct for channel error(s)), so we’ll do the same thing as above – start with a diagram. My hope is going over the `Encoder` first helps us only really focus on the “new” stuff, otherwise it should feel like running the `Encoder` backwards. Here, we start with an incoming stream of IQ, where we will process scan detections as they come in from our Schmidl-Cox detector and burst Scanner. This will give us a “snippit” of IQ, sized to exactly our Burst. We’ll begin to correct our IQ samples by first doing frequency estimation and correction in the time domain using our preamble and ofdm configuration. We’ll then do a series of inverse FFTs to extract each OFDM symbol in our `Burst`, where we can then do channel estimation and correction. With the OFDM symbols (hopefully) good enough, we can now map each data subcarrier back to bits, and unapply whitning. The resulting bits are then chunked back up into LDPC messages, which are then checked, and concatanated data extracted. Finally the bits are turned back into bytes, which are written to our output buffer. However, before we get into the code to do this – there’s one last detail. We know bursts won’t overlap (if they do, it’s likely not possible to recover right now – even though other PHYs can and do), so any time we see something we believe to be a burst, we can skip ahead by the burst’s (constant) length within the IQ, and avoid trying to decode anything else in there. The nice side-effect here is this also gives us an interesting property for the `Decoder` – namely, we know the maximum number of `Burst` detections we can get for a given block of incoming IQ data if they were packed end-to-end – and we can pre-allocate the memory we need, avoiding allocations for every demodulation attempt (which may or may not even be a valid Burst). This pre-allocated block of memory to hold the burst’s data is something that I’ve called a `frame buffer` internally. Each frame buffer contains exactly sized buffers to hold decoded information from the `burst` – an `iq` buffer that is exactly the same number of `samples` required to encode the preamble and data, exactly the number of bits needed to store pre and post FEC data, pre-allocated byte array, etc. Not shockingly, the code looks like this: #[derive(Clone)] pub struct FrameBuffer { /// Corrected IQ samples pub samples: Samples, /// post-correction OFDM burst pub burst: Burst, /// demodulated bits from the OFDM burst pub bits: Vector, /// demodulated bits from the OFDM burst, /// after FEC, and cleaned pub raw_bits: Vector, /// Layer 2 contents of the Frame pub contents: Vec<u8>, } Of course, that alone is handy – but we need to use them. So let’s go ahead and do what we promised above – each `Decoder` uses a fixed number of pre-allocated `FrameBuffer`s to store packets in-flight, packed into what is, creatively, called `FrameBuffers` within my code. As an aside, I likely should have called this a Memory Pool, since that’s the common and accepted name for this design pattern – but being stuck with unfortunate names is the burden of those of us who stumble into sensible ideas over time. The only nuance here is that I use the pools strictly sequentially – we only “`save`” the `FrameBuffer` if the LDPC checksum is correct, allowing us to only keep track of how many successful packets we have and being able to get the valid `FrameBuffer`s, rather than storing a handle to each `FrameBuffer` as we go – a promise that most memory pools do not make, since blocks can usually be taken and returned in any order. Let’s go ahead and do the whole `Decoder` dance now: // (lightly edited for clarity) impl Decoder { .. /// Process incoming IQ for Pigeon Bursts, and /// demodulate them. pub fn decode( &mut self, buf: &[IQ], ) -> Result<Vec<(Detection, &FrameBuffer)>, Error> { let mut ret = Vec::new(); let mode = self.scanner.mode().clone(); // reset the "valid frame buffer count" back to 0 self.frames.reset(); // call the scanner and get scan detections for // this block of iq (`buf`) for detection in self.scanner.scan(buf) { // for each detection, we're (only) going to process // the iq snippit, but pass along the metadata // such as SNR. let ScanDetection { snippit, snr, range, m, } = detection; // grab the next free frame buffer to work within. let frame_buffer = self.frames.next_mut(); // Copy the snippit into the frame buffer (a mutable // location) frame_buffer.samples.copy_from_slice(snippit); // estimate the frequency offset based on the // Burst's Schmidl-Cox preamble. let preamble_fo = preamble::estimate_frequency_offset( &mode.preamble, mode.rate, &frame_buffer.samples[..mode.preamble.samples()], ); // Shift the IQ stream by the estimated frequency // offset -- hopefully we're closer to 0Hz frame_buffer.samples.shift(mode.rate, preamble_fo); // estimate the frequency offset based on the // each burst's **cyclic prefix** -- exactly like // we did with the Burst Schmidl-Cox preamble, // but this time on each OFDM symbol. let ofdm_fo = ofdm::estimate_frequency_offset( &mode.ofdm, mode.rate, &frame_buffer.samples[mode.preamble.samples()..], ); // Shift the IQ stream closer yet; hopefully this // is a very small nudge even closer still to 0Hz. frame_buffer.samples.shift(mode.rate, ofdm_fo); // Do a bunch of inverse fft operations for each // OFDM symbol, filling the frequency-domain Symbol // structs in `frame_buffer.burst` (Burst) struct. // // this will also do channel estimation and // correction before returning. self .decoder .transform( &mut frame_buffer.burst, &frame_buffer.samples[mode.preamble.samples()..], )?; // "demultiplex" each data subcarrier's frequency-domain // IQ constellation point, setting the correct bit range. self.decoder.demultiplex( &mut frame_buffer.raw_bits, &frame_buffer.burst ); // unapply whitening by XOR-ing the buffer with // the well-known whitening vector. frame_buffer.raw_bits = frame_buffer.raw_bits.xor( &self.whitening); // verify that the LDPC message(s) are all correct, // and if so, concatanate the the data bits (no check // bits) to the `bits` vector. if self.fec.decode( &mut frame_buffer.bits, &frame_buffer.raw_bits ).is_err() { // this is where invalid packets fail. we gave it a good go. // next packet please. continue; } // copy the raw bits out, as bytes, to the `contents` buffer. frame_buffer.bits.copy_as_bytes(&mut frame_buffer.contents); // store metadata/metrics on the demodulation. ret.push(Detection { m, snr, index: range.start, }); // save the contents of this frame buffer (don't // reuse this buffer next go-around). self.frames.save(); } // We're going to take out the borrow on the frame at // the end since we don't want to deal with telling the // compiler via code gymnastics that the mut and non-mut // borrows are OK since they're non-overlapping. Ok(ret.into_iter().zip(self.frames.iter()).collect()) } } Phew. That was kinda a lot. In fact it’s basically the whole thing. This function is as close to “how do you read an OFDM packet” as it gets, and perhaps the most important part of this whole series. Beyond that, though, this is a huge conceptual unlock. This means we now have an **incredibly** powerful primitive; the ability to take bytes and go to/from IQ samples over the air. Let’s talk about pigeon modes →
k3xec.com
September 8, 2026 at 3:12 PM
Paul Tagliamonte: Mode A (Part 9/12) 🕊️
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series. **If you're looking for it, the intro to the PHY (layer 1) that this belongs at a high level and listing of the parts that make it up (including this one) is on the phy post**. While developing Pigeon, I’ve called the group of all the configuration of the Layer 1 PHY parameters the “Mode”. I’ve experimented with a few different “modes”, but one in particular has been the most resilient to the innumerable mistakes and bugs i’ve wrought into existence – and that is the first mode I wrote down, “Mode A”. This is even (mostly) backwards compatible to my original Go implementation of Pigeon Mode A (back in 2022) over the air, and has largely withstood the problems I’ve thrown at it. I’ve removed the bulk of the support I wrote out for other modes, but i’m likely to bring them back over time as I use pigeon to learn more (such as “Mode B” (QAM-16), “Mode C” (QAM-16 NUC), and “Mode AW” which is the exact same as Mode A, except 5MHz in bandwidth. More to come on those as I get further along – but for now let’s braindump the parameters i’ve picked out for Mode A: Attribute | Value | Description ---|---|--- Rate | 2.5 MHz | Sampling Rate / Bandwidth LCG | 3149721335 | LCG "RNG" whitening constant (randomly selected) Preamble | seq=16, order=4, count=2 | (this is as-written in the preamble post) Modulation | QPSK/QAM-4 | 2 bits per data subcarrier FFT Size | 64 | Cyc Len | 16 | Cyclic Prefix length (16 IQ samples) Symbols | 168 | Number of OFDM Symbols LDPC Table | 802.3an | (this is as-written in the ldpc post) Raw Bits | 14448 | 1806 bytes (168 symbols, 86 data bits per symbol) LDPC Count | 7 | Number of packed LDPC encoded messages Data Bits | 12061 | 1507 bytes The last bit to describe here is the Subcarrier Plan. The plan is ordered “negative first” (meaning the 0th bin in-memory is the most negative frequency domain bin of the fft), and within a Mode A OFDM symbol, there are 64 frequency domain bins (so, just to make it explicit: 64 ‘subcarrier usages’ that make up our Mode A ‘subcarrier plan’). We’ll follow the same structure and conventions that we went through in the post all about OFDM Symbols – which means, we’ll need to place our guard bins, data bins, and pilot bins. I’ll include a copy-paste-able version of the images to follow at the end. # Guard Bins First up, let’s place our guard bins. As we’ve already gone over, we’re looking to clear some space right up against the high and low end of the frequency range, so let’s go ahead and do that: I gave up the center bin (0 Hz) and 8 of the 64 bits on each side (1/4 of the signal!) to give myself a bit of elbow room. This is perhaps definitely a bit overkill, but it’s been an extremely robust choice. If you multiply that through, this accounts for 312.5 kHz of frequency domain “padding” at the high and low end of the bandwidth, or 625.0 kHz of bandwidth which is not to be used. # Pilot Bins Next up was the pilot bins. We’ve already gone over the purpose (and use) of our pilots, but I’ve found there to be an art to the placement of the pilots. Interpolation between pilot bins has turned out to be very reliable, but extrapolation, on the other hand, has been a major pain, for reasons I don’t fully understand yet. My intent in placement was to pick out roughly even stretches of data bins bracketed between pilots, with as few data subcarriers as practical “outside” of a pilot (using extrapolation). I’ve played a bit with my AGWN simulator(s), as well as logging errors between two SDRs, and the configuration I have this in has been fairly resillant (for whatever reason), and withstood a few rounds of tweaking. # Data Bins Almost as an afterthought – all of the remaining bins become data bins. This puts the total number of data bins at 43, which, since Mode A carries data in QPSK/QAM-4 (two bits per data subcarrier), means we can carry 86 bits of data per OFDM symbol. That fairly modest capacity is largely due to the modulation scheme (or fft size, but increasing that has been … fraught) we’re using for Mode A – but I’ve made up for it by including `168` OFDM symbols in a single burst in order to have enough data to carry IP traffic without splitting the packet into two bursts. With all that designed and on paper, we’re ready to start to tackle the next layer up – our Layer 2, named, creatively, “link”. Let’s send some link layer data → * * * **Following along at home?** Nice! As promised, I've put the full plan copy-pasted from the pigeon source tree below so that no one has to feel the need to transcribe this from the images above. Unlike most of the things I've "left to the reader", manual transcription from images is not a particularly useful task for anyone to do. The following table is Mode A’s OFDM Subcarrier Plan. This is in negative first ordering (meaning the 0th member is the most negative fft bin, and the Nth is the highest frequency fft bin). SubcarrierPlan([ Guard, Guard, Guard, Guard, Guard, Guard, Guard, Guard, Data, Data, Data, Pilot(iq!(-1.0, 0.0)), Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Pilot(iq!(1.0, 0.0)), Data, Data, Data, Data, Data, Data, Guard, Data, Data, Data, Data, Data, Data, Data, Pilot(iq!(0.0, -1.0)), Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Data, Pilot(iq!(0.0, 1.0)), Data, Data, Data, Guard, Guard, Guard, Guard, Guard, Guard, Guard, Guard, ]) **Still following along at home?** Nicer! I've put the preamble sequence promised previously copy-pasted from the pigeon source tree below in case that's actually important (I don't think it is, but...). The following table is Mode A’s frequency-domain preamble. This is, as above, in negative first ordering. I don’t actually think these values matter much (at all)? – but in case they do, here’s what I have. I muck with these a lot and haven’t found many changes in quality of detection or frequency correction yet. [ IQ::new(0.0, 0.0), IQ::new(0.0, 0.0), IQ::polar((TAU / 13.0) * 2.0, 1.0), IQ::polar((TAU / 13.0) * 3.0, 1.0), IQ::polar((TAU / 13.0) * 4.0, 1.0), IQ::polar((TAU / 13.0) * 5.0, 1.0), IQ::polar((TAU / 13.0) * 6.0, 1.0), IQ::polar((TAU / 13.0) * 7.0, 1.0), IQ::polar((TAU / 13.0) * 8.0, 1.0), IQ::polar((TAU / 13.0) * 9.0, 1.0), IQ::polar((TAU / 13.0) * 10.0, 1.0), IQ::polar((TAU / 13.0) * 11.0, 1.0), IQ::polar((TAU / 13.0) * 12.0, 1.0), IQ::polar((TAU / 13.0) * 13.0, 1.0), IQ::new(0.0, 0.0), IQ::new(0.0, 0.0), ] Let’s send some link layer data →
k3xec.com
September 8, 2026 at 3:12 PM
Paul Tagliamonte: You would never break the chain (Part 10/12) 🕊️
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series. Now that we have a working Layer 1, we have a way to send a block of bits from one place to anyone who cares to listen to us. This is very welcome news, but we are now facing a new, different and just as fun question – what shape should that data take? ⏳ Need a bit more of a crash course on what "Layer 1" and "Layer 2" mean? No problem, I wrote up short summary here to help. Given our incredibly limited functionality of our nodes, we could definitely skip all this work and just stuff an IP packet into the link; but I decided to not since I am (_eventually_) interested in adding some sort of spanning tree-**like** protocol to implement network switching so not all nodes need to communicate directly with all other nodes – but that day is not today. Given i’m going to stub most of that out, let’s take a look at what some similar Layer 2 protocols use – things like Ethernet or WiFi frames. Both contain structured information regarding the transmitter, desired recipient, type of data, and the higher-level data itself (such as IP packets). As a result of attempting to learn from others, the Pigeon Layer 2 (called, simply, “`link`”) is also split into a fixed-length header, followed by the contents described by the header. dst mac src mac callsign type length payload The header is a fixed-length (23 byte) structure, which contains the source MAC address (`src mac`), destination MAC address (`dst mac`), the ITU coordinated ham radio callsign of the control operator of this message (`callsign`), the type of payload to follow (`type`; defined below), and the length of the data to follow the header (`length` as a 16 bit big-endian unsigned integer). The `type` field indicates how the `payload` is to be interpreted – currently I’ve only defined 3 possible payload types so far: Type | Description ---|--- 0x01| Raw (testing only) 0x02| Cal 0x04| Ipv6 Keen observers will perhaps infer that there _used_ to be an `Ipv4` type at `0x03` – which is true – however, i’ve since removed it since i’ve never once used it and the codepath was more trouble than it was worth. As is my wont, I’ve optend to just lean into Ipv6-only IP transport – it’s easy enough to shim ipv4 in, if someone REALLY wanted to using something like `64:ff9b:1::/48` and a bit of code in the transmitter/receiver (or even using something like jool and unbound’s dns64-prefix at the router). I don’t think I’ll bring it back, but just in case I have to for some reason in the future, it’s there. Additionally, friends of the pod may also recognize that this structure is basically the exact same structure as what I had in PACKRAT, which, is also true. I started this project off maintaining interoperability in the Layer 2 for pigeon and packrat, but at some point just gave up on it during one of the many cleanups. I’m hopeful I can maintain compatibility going forward, and that won’t have to muck with this header too much more. We’ll see what happens once I start to push the bounds of what is possible with pigeon. Hopefully it feels like carrying IP data inside this frame to be a pretty self-explanatory exercise – the 0th byte of the `payload` is the 0th byte of an IPv6 header (followed by all the usual stuff, like UDP or TCP header(s) and any carried data, just like you’d find anywhere else. Pigeon’s calabration protocol →
k3xec.com
September 8, 2026 at 3:12 PM
Paul Tagliamonte: can you hear me now? good! (Part 11/12) 🕊️
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series. **If you're looking for it, the intro to the Link (layer 2) that this belongs at a high level and listing of the parts that make it up (including this one) is on the link post**. Built-in to the pigeon link protocol is a message type called `cal` (short for, you guessed it, `calibration`). A pigeon `frame` with a type of cal (which is `0x02`) carries a JSON encoded payload in the body, which can either be a beacon, requesting signal reports in response, or a report, describing the received `beacons`. This serves a few interesting purposes – firstly, network operators can better understand the coverage footprint, propagation under different conditions, and how gain impacts reception when tuning for the lowest practical power levels. Secondly, this _can_ be used (and I plan to eventually implement!) to construct a mapping of minimum power level and peer mac address to dynamically control the transmission power based on the destination station. That being said, for now, all I’ve used this for is getting a rough sense for what gain value(s) make sense between two nodes (manually). In the future, beyond all the fancy neighbor gain stuff, I plan to wire this into the daemon to happen automatically, “debouncing” for `beacon` and `report` messages, such that transmitting stations only beacon, and receiving stations only report a max of once over some time period for a given peer. ## Version Given all the above, I do intend to make some massive changes to this protocol (I promise to blog all about it) when I get around to hacking on switching Layer 2 frames within a network segment. Just to avoid having to dig myself out of a hole later, I’m going to explicitly send (and check) the `version` field to avoid having a big “flag day” switchover or needing to use a new `link` `type`. Version ID | Description ---|--- `V1`| this version of the cal protocol ## Location Both flavors of `cal` messages (`beacon` and `report`) may contain a location, which is the location that the `beacon` was transmitted, or for or the location where the `beacon` was heard for a `response`. This can be used to derive a coverage map and to (operationally) better understand what stations should be within range, and generally what gain level(s) are effective. All fields assume `WGS84` latitude and longitude values, and elevation is distance, in meters, above the `WGS84` ellipsoid – **NOT** height above sea level, or altitude above the ground. Field | Description ---|--- lat| WGS84 Latitude lon| WGS84 Longitude elevation| height, in meters above the WGS84 ellipsoid ## Sequence Each `beacon` contains a `Sequence` identifier, which is used to communicate which message number is being heard, and how many total were transmitted by the originating station. The current approach with `Beacon` messages is to transmit some number of `Beacon` messages at different gain levels, each with a unique Sequence identifier. Field | Description ---|--- number| beacon sequence number total| total number of beacons transmitted ## Gains Recorded gain setting(s). For a `Beacon` this indicates the gain settings (which, in spite of its name, includes things like amplifiers, or attenuators). Changing this over different `Beacon` frames enables a better understanding of what an appropriate gain level is for the transmitting station over time. Field | Description ---|--- name| gain stage name db| gain value, in dBm ## Cal All messages contained in a `cal` frame are of this type. The `type` field communicates if this is a `Beacon` or `Report` message type. Type | Description ---|--- beacon| sent intermittently by idle stations report| reception report in response to a `beacon` # Beacon A `beacon` message may be sent periodically by pigeon nodes capable of transmitting to announce their prescience to peers and, implicitly, to receive signal reports from nearby listeners who are capable and configured to transmit reports. The `beacon` JSON message is made up of the following fields: Field | Description ---|--- version| version enum value gains| gains object sequence| sequence number location| location object An example `beacon` looks, unsupprisingly, as follows: { "type": "beacon", "version": "V1", "sequence": { "number": 2, "total": 5 }, "gains": [] } # Report A `report` message may be sent in response to a beacon message by pigeon nodes capable of receiving and transmitting to assist with setting the lowest usable gain value, and to better understand the area of coverage and propagation. The `report` JSON message is made up of the following fields: Field | Description ---|--- version| version object location| location object An example `report` looks as follows: { "type": "report", } With all that out of the way lets send some ip →
k3xec.com
September 8, 2026 at 3:12 PM
Paul Tagliamonte: IP over Avian Carriers (Part 12/12) 🕊️
🕊️ This post is part of a series called "Pigeon". If this is the first post you've found, it'd be worth reading the intro post first and then looking over all posts in the series. The final step to all of this was to tie together all my PHY RF code, Link layer parsers, and my background with operating systems to make this all feel like a normal thing my computer should be doing. At the end of the day, I want my host system to know how to talk with a `pigeon` daemon, so I don’t have to reimplement basically everything else. My ability to use normal tools like `curl` or `ping6` is pretty important here, so I need to reach for my old friend, the TUN interface. The TAP/TUN interface allows the kernel to route ethernet frames (TAP) or ip packets (TUN) to a userspace program responsible for handling delivery and reception – avoiding the need for a kernelspace driver for something that can be handled in userland. 🔒 wondering if doing this violates FCC Part 97 rules? I wrote up notes on my test setup for exactly this question, but the short answer is "no"! I’m no stranger to playing with TAP/TUN, so this was pretty easy to snap together – although this time I avoided the whole ethernet proxying thing (to side-step lossy translations, maintaining two sets of mac address tables, and handle proxying NDP/ARP messages) – it was kinda a bad idea last time – so I just used `TUN` and straight IP for now. While implementing this, I decided I’d make a key assertion about all pigeon networks – namely, all pigeon IPv6 networks are a `/64` in size, no more, no less. The reason why I’m doing this here is that, since `pigeond` does still does need a MAC address for the pigeon layer 2 protocol, we can write our daemon to always use SLAAC to set the TUN IP address without any new information. Which leads us to a bit of an aside, but I have a point, I swear. A few years ago, my recreational RF adventures have lead me down a path where I decided to engage with ARIN to solve (once and for all) the massive headache I was running into with IPv6 numbering (really: always renumbering) my multi-site radio processing networks. It’s a lot of work to keep running correctly, but it’s solved a huge amount of problems for me. The only “internal” thing we really need to outline for this post is that, at the highest level, my network (`paultag.net`) is split into an IP plan that looks roughly like: Prefix | Description ---|--- /44| my full allocation of IP space /48| 15 "regions" /54| 64 "sites" per region. A "site" is assigned to a physical or logical location. /64| 1024 subnets per site. A subnet used by directly attached devices. For this exercise, I used IP space from `paultag.net`’s experimental region (“region 8”), named `side.band` (`2602:810:6008::/48`) to connect my RF lab (“site 1” - `2602:810:6008:400::/54`), and my two pigeon-specific subnets, “subnet 0” (`2602:810:6008:400::/64`) and “subnet 1” (`2602:810:6008:401::/64`) to my wider network. The first subnet (“subnet 0”) is a simple ethernet network to enable my RF-only nodes to communicate with the `side.band` gateway. The second subnet (“subnet 1”) is an RF-only pigeon network local to my lab. # back to radios With all that set up, I assigned my first two nodes their MAC addresses, and set up the local RF only network segment. The nodes I brought online were the following: Callsign | IP ---|--- `K3XEC/MN`| `2602:810:6008:401:8e1f:64ff:fe35:4001` `K3XEC/TH`| `2602:810:6008:401:8e1f:64ff:fe35:4002` # testing the pigeon network And with that, I could begin to test that the host operating systems and RF links could properly exchange data locally from SDR to SDR. We can use `ping6` to see if a plain-ole `ICMPv6` ping round trips between hosts correctly: $ ping6 2602:810:6008:401:8e1f:64ff:fe35:4001 PING 2602:810:6008:401:8e1f:64ff:fe35:4001 (2602:810:6008:401:8e1f:64ff:fe35:4001) 56 data bytes 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=1 ttl=64 time=426 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=2 ttl=64 time=397 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=3 ttl=64 time=419 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=4 ttl=64 time=418 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=5 ttl=64 time=436 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=6 ttl=64 time=391 ms 64 bytes from 2602:810:6008:401:8e1f:64ff:fe35:4001: icmp_seq=7 ttl=64 time=397 ms And it does! Latency is horrid (and there’s a bunch of tx artifacts that cause issues for us) – but both of those things are problems for later. Let’s see how it handles a TCP connection by firing off a quick cURL across the Pigeon network: $ curl http://[2602:810:6008:401:8e1f:64ff:fe35:4001]:8000/testing.txt The rock dove (Columba livia), also known as the common pigeon or rock pigeon (but see also Petrophassa), is a member of the bird family Columbidae (doves and pigeons). As expected, our “remote” end here running the server reports the correct peer IP address, which is another indication (beyond the log messages and blinking LEDs) that we’re routing over our TUN interface. Serving HTTP on 2602:810:6008:401:8e1f:64ff:fe35:4001 port 8000 (http://[2602:810:6008:401:8e1f:64ff:fe35:4001]:8000/) ... 2602:810:6008:401:8e1f:64ff:fe35:4002 - - [28/May/2026 12:53:21] "GET /testing.txt HTTP/1.1" 200 - That … worked? First shot! Nice! It’s pretty slow and seems like we have a lot of packet loss, but it does, however, beg the question – can it nethack? ## nethack! Yes! It can nethack! No clickbait here. The way I went about this one is a bit anit-cimatic – I set up a `nethack` server (using `inetd` in this case) on one of the hosts’ `pigeon0` network interface, and hit that port over RF from the other: However, when playing it, it becomes very obvious (as you can likely see) that there’s a fair amount of packet loss (understandable) and probably some packet collisions taking place. ## iperf Let’s try and put a number to exactly how bad the bandwidth and packet loss is by running `iperf` between the two pigeon hosts over rf: $ iperf -c 2602:810:6008:401:8e1f:64ff:fe35:4002 ------------------------------------------------------------ Client connecting to 2602:810:6008:401:8e1f:64ff:fe35:4002, TCP port 5001 TCP window size: 16.0 KByte (default) ------------------------------------------------------------ [ 1] local 2602:810:6008:401:: port 58248 connected with 2602:810:6008:401:8e1f:64ff:fe35:4002 port 5001 [ ID] Interval Transfer Bandwidth [ 1] 0.0000-20.2348 sec 76.8 KBytes 31.1 Kbits/sec Shockingly, not **nearly** as bad as I thought it was going to be. Given I’ve spent exactly zero time making this operate to a level that I would call acceptable, this is a very fucking solid start. I expect I could get that number up if I spent a few weeks on it – it’s just not been a priority at any point yet (and the first time I’ve instrumented it, even!). This’ll be good enough to get started. Let’s see what else we can pull off here. ## IP multicast to some rtl-sdrs Back when I designed what I wanted Mode A to look like, I intentionally picked a signal bandwidth that could be received by an `rtl-sdr` – so let’s put that to use. It may go without saying, but just to say it – the rtl-sdr can not transmit, so this will be capable of receiving pigeon frames – but not sending any in reply. However, this means I can use a bunch of low-cost computers (raspberry pi-class), and low-cost SDRs (rtl-sdr) and still receive IP traffic from transmitting pigeon network stations. This could be a lot of fun for things like fountain coding a data stream, or adapting multicast streaming protocols to work over RF links. Anywho, I swapped my “far” end to an rtl-sdr (one config file change!), and figured I’d start with some (basic) multicast traffic, transmitting the time once a second: $ while [ true ]; do echo $(date +%s) \ | socat - UDP6-DATAGRAM:[ff02::114%pigeon0]:62804 sleep 1 done If I had more time to burn, I was planning on bridging APRS traffic to UDP multicast within a pigeon network subnet. However, since I’m already 4 years late on this blog post, I figured this would be enough for now (and you can imagine that fun project in this space if you so wish!) I fired up `pigeond` again (except this time connected to an rtl-sdr), and was pleasantly surprised to be greeted by some decoded traffic right off the bat: ⪧ [k3xec/mn] 8c:1f:64:35:40:02 ⇢ 00:00:00:00:00:00 ipv6 fe80::23ee:2969:53f7:b332 ⇢ ff02::114 17 (UDP - User Datagram) ⪧ [k3xec/mn] 8c:1f:64:35:40:02 ⇢ 00:00:00:00:00:00 ipv6 fe80::23ee:2969:53f7:b332 ⇢ ff02::114 17 (UDP - User Datagram) ⪧ [k3xec/mn] 8c:1f:64:35:40:02 ⇢ 00:00:00:00:00:00 ipv6 fe80::23ee:2969:53f7:b332 ⇢ ff02::114 17 (UDP - User Datagram) ⪧ [k3xec/mn] 8c:1f:64:35:40:02 ⇢ 00:00:00:00:00:00 ipv6 fe80::23ee:2969:53f7:b332 ⇢ ff02::114 17 (UDP - User Datagram) Of course, I took a `tcpdump` to confirm for completeness sake that the traffic actually made it out of our TUN interface: $ tcpdump -i pigeon0 22:39:34.808705 IP6 (flowlabel 0x92a0e, hlim 1, next-header UDP (17), payload length 19) fe80::23ee:2969:53f7:b332.35911 > ff02::114.62804: [udp sum ok] UDP, length 11 22:39:36.429887 IP6 (flowlabel 0x4dc84, hlim 1, next-header UDP (17), payload length 19) fe80::23ee:2969:53f7:b332.50026 > ff02::114.62804: [udp sum ok] UDP, length 11 22:39:39.365977 IP6 (flowlabel 0x224d5, hlim 1, next-header UDP (17), payload length 19) fe80::23ee:2969:53f7:b332.36308 > ff02::114.62804: [udp sum ok] UDP, length 11 22:39:40.944889 IP6 (flowlabel 0x4721c, hlim 1, next-header UDP (17), payload length 19) fe80::23ee:2969:53f7:b332.35003 > ff02::114.62804: [udp sum ok] UDP, length 11 Looks great! `tcpdump` is showing multicast packets show up (as we assumed they would), on the `pigeon0` interface, on the machine connected to an `rtl-sdr`. Of course, any replies will get sent to the bit bucket, but it can definitely decode things just fine! Very fucking cool. Well right, ok! Let’s go back to two rx/tx radios, and see what we can do with our newfound network stack over ham radio frequencies – let’s try to do some fun (and traditional!) ham radio things with it! ## Winlink Winlink is a ham radio mail relay system for ham radio operators to send, receive or relay mail over the internet, or RF (usually HF or VHF/2M). Winlink relays are accessible via whatever transport you can find – most commonly `telnet` (using the internet), `ax.25` (usually 2m VHF) or `VARA HF` (unsurprisingly, on HF). I use pat as my Winlink client – it’s written in Go, doesn’t require windows, and is just generally nice to work with. Let’s try the easy thing first – let’s connect by proxying the Winlink server into the pigeon network using `socat` (lightly edited to remove date/times) $ pat connect pigeon Connecting to WL2K (telnet)... Connected to [2602:810:6008:401:8e1f:64ff:fe35:4002]:8772 (tcp) [WL2K-5.0-B2FWIHJM$] ;PQ: 54509561 CMS> >FC EM OLU6BP5HKMG2 240 205 0 >F> 95 FS Y Remote accepted OLU6BP5HKMG2 Transmitting [Hello, World] [offset 0] Hello, World: 100% FF >FQ Disconnected. $ Lo and behold, shortly after, I got this delightful message to my email address, relayed in from WINLINK: From: K3XEC@winlink.org Reply-To: K3XEC@winlink.org Subject: Hello, World To: paultag@[...] Message-ID: <OLU6BP5HKMG2@winlink.org> MIME-Version: 1.0 X-MARSPrecedence: Routine X-WL2KPrecedence: Routine Content-Type: text/plain Content-Transfer-Encoding: 8bit Hello, World! The only shame is I won’t be able to check in to a winlink wednesday using this scheme unless I further proxy this message over AX.25 instead of relaying to Winlink’s servers over telnet (which, to be fair, is definitely also possible – I just got lazy when I glued this one together – see note above about being 4 years late on this post). But, you know, connecting to a host that is using `socat` to proxy a connection to an internet resource is interesting but – you know what, fuck it – hang on, dear reader – let’s bang a hard left turn and just ship this thing hard and **directly connect it to the internet**. Let’s take our dinky, home-built PHY and Layer 2 and see if we can wire it directly into the internet – something that, every time I go to think about it, reminds me of Tim FitzHigham and his crapper. # Crossing the english channel in a bathtub Ok, ok. I decided to bury the lede a bit here – I didn’t mention that the `side.band` network is currently BGP announced. Although we haven’t used it – this does mean that we’re most of the way to sending packets to the wider internet, and we **should** be able to “just” fix a few routing tables, and see packets begin to flow. After tweaking the local routing tables (and restarting `pigeond` for good measure), I decided to test my newfound connectivity by pinging something over our new network transport. ## ping github Why don’t we start with the world’s premier software engineering platform, operated by one of the largest companies in the world, **GitHub**! After all, they have an all knowing (and, apparently, arguably sentiant?) AI on hand to instantly and automatically fix any stray reliability issues in the background, so we should definitely see replies right off the bat: $ ping6 github.com ping6: github.com: Address family for hostname not supported Wait, oh no – that can’t be right? After all, it’s 2026, and both Google and CloudFlare (in North America) are reporting over half of all traffic they see is IPv6 – and GitHub still doesn’t support IPv6? **Definitely not** , this is for sure a bug with my code or network. ## lets ping something that supports ipv6 instead That being said, just for completeness sake, since that error is also given when there’s no IPv6 DNS record, let’s go ahead and double check with Hurricane Electric too, you know, just to be sure. $ ping6 he.net PING he.net (2001:470:0:503::2) 56 data bytes 64 bytes from he.net (2001:470:0:503::2): icmp_seq=1 ttl=53 time=514 ms 64 bytes from he.net (2001:470:0:503::2): icmp_seq=2 ttl=53 time=230 ms 64 bytes from he.net (2001:470:0:503::2): icmp_seq=3 ttl=53 time=248 ms 64 bytes from he.net (2001:470:0:503::2): icmp_seq=4 ttl=53 time=246 ms 64 bytes from he.net (2001:470:0:503::2): icmp_seq=5 ttl=53 time=265 ms 64 bytes from he.net (2001:470:0:503::2): icmp_seq=6 ttl=53 time=240 ms Well, shit. Right, OK, i’ll be damned. 18 years in and GitHub still can’t crack that nut. ## cURL works! Right, anyway, yes, back on track – good news! Our uplink is up and routing, and wait, holy shit! Check it out! `pigeon` is exchanging packets with the internet and no one is any the wiser! Literlaly amazing. Let’s try a cURL across the internet now (although no TLS allowed, so, http only for now): $ curl -6 -I http://facebook.com HTTP/1.1 301 Moved Permanently Location: https://facebook.com/ Content-Type: text/plain Server: proxygen-bolt Connection: keep-alive Content-Length: 0 ## IRC works, too Sweeeeet. That all works! Forget HTTP, let’s do some other 90’s era stuff, it’s high-time to log into IRC with a quick `/connect -notls`, and see what’s going on in the `#debian-hams` channel – pleased that I got online fairly quickly, and was able to even talk to myself! ## THE GOPHERSPACE Naturally, let’s keep this train of nostalga running, and give the 2026 gopherspace a shot. I know the kind folks over at tilde.town (hello, townies!) have a robust gopherspace, so let’s give it a dial! Let’s try and see if we can load vilmibm’s slug over gopher: Yes! I forgot to make this one a video, so no gif. I did wind up having a bit if trouble with a few gopher clients and IPv6 support – I may send some patches if I can find the time. # So, what’s next? Alright, that’s it. I have a few more fun ideas but they’re going to have to wait for another day. Carrying IP is fun and all but kinda not the point behind pigeon, after all. Rather than trying to make this into “a thing”, I’m planning on exploring the loose ends first – different types of modulation schemes (like QAM-NUC), implementing LDPC error correction and some layer 2 logic into the `pigeond` (like switching traffic, and gain control). I also plan on spending some time with my (currently, very basic) simulator to better dial in tradeoffs throughout the stack. Since, structurally, `pigeon` is something I feel like I can work with, I’m hoping i’ll be able to find the time for some (much smaller!) followup posts without it taking 4 years this time. If I do, they’ll show up under the pigeon tag – and I’ll be sure to update this post with a link below (and the intro post). I’m hoping that this series (which was supposed to be one post) was helpful to someone out there – if it was, feel free to reach out and let me know!
k3xec.com
September 8, 2026 at 3:11 PM
Happy Labor Day! Thank you to all of the workers out there for all of your hard work and dedication! Today is the perfect day to treat yourself to rest, relaxation, and maybe even a little something special.

#HappyLaborDay #OldeWorldTreasures
September 7, 2026 at 6:16 PM
"older people use the longer, more temporally specified variants "wait a minute" and "wait a second", while "wait" alone is increasing in apparent time, with women leading its advance. " from Wait, It’s a Discourse Marker (2021) by Sali Tagliamonte
September 7, 2026 at 5:27 PM
Do your interests include a love of art? Why not wear a piece of art featuring the poet Petrarch and the artist Michelangelo with our TagliamontE Renaissance Artists 925SS/YGP Lavastone Cameo Bracelet?
www.tagliamontejewelry.com/store/bracel...
September 4, 2026 at 6:43 PM
Happy September! We’re celebrating sapphires with these TagliamontE Athena 14K Venetian Cameo Earrings.
www.tagliamontejewelry.com/store/earrin...
September 2, 2026 at 11:05 PM