I have published a draft proposal describing an interoperable method for binding atproto users to Iroh endpoints they can be reached from, for use in social P2P applications:
This is an open request for coordination and critique from any relevant or interested parties in the atproto and/or iroh ecosystems; feedback is welcomed for any part of the proposal. The key components described are:
A mapping between an Iroh EndpointID and a did:key identifier.
A Lexicon record type for device attestation: a signed binding from an atproto account to an Iroh node.
A discovery procedure: given a signed-in user, how to find the active devices of people that user follows.
A trust model: how to decide whether an attested device is safe to connect to.
We experimented with Iroh in the predecessor of Roomy and have been fully intent on integrating with it again when possible. We’d be very accommodating of anyone wanting to implement an iroh-based feature in Roomy for real-world testing
For instance, p2p audio calls as demoed in callme is fairly low-hanging fruit. @lou.gg already implemented audio calls in Colibri (with the usual livekit to begin with) so that might the easiest place to start.
Earlier sketch of iroh+atproto:
the p2p browser Beaver is also building along these lines.
This is great and I’m so happy to see you trying to align the community on a way of doing this!
Some thoughts after reading:
What made you decide on adding `nbf` and `exp` fields? I personally think that you can model revocation purely on the device itself - it can rotate its `EndpointId` as often as it wants. I doubt revoking the atproto record while keeping the `EndpointId` for other things is something applications need, especially if it’s time-bound. I’d probably skip these fields and recommend application developers to implement revocation via rotating their `EndpointId` instead.
It would be nice if we could store the whole `EndpointAddr` in atproto, not only the `EndpointId`. This way, you can have atproto also work as an address-lookup service. This means you’re leaking a bit more information, because the `EndpointAddr` in theory allows you to publish IP addrs. However, by default iroh won’t publish these. Instead, only the endpoint’s home relay URL is stored. This is all that’s required to address the endpoint and at most roughly leaks which continent the device is on. In any case - an `EndpointAddr` is also allowed to be only the `EndpointId` and otherwise empty, so it’s just a strict superset of the information currently shared, and any client can decide to turn off publishing anything besides the `EndpointId`. (Also note: In terms of privacy the current version is not much different: Using the iroh configuration defaults, once you have the EndpointId, anyone can ask dns.iroh.link to give you a signed record for that EndpointId which tells you its home relay URL. Storing it directly in the atproto record just skips having to do this step.)
I agree with some of the things @thoth.ptnote.dev wrote on the iroh discord. Specifically the remark about how useful an application-generic way of endpoint discovery is. Some somewhat independent thoughts on that:
Doing APLN-per-application might not work in all cases. The ALPN’s job is to help with protocol negotiation within a single QUIC connection. Some applications use multiple APLNs (e.g. iroh-blobs + iroh-gossip + something else).
The signed envelope format is useful beyond being embedded in atproto. E.g. some applications might want to connect incoming connections to atproto identities. For this, you’d send the envelope as the first message in the envelope.
Generally, EndpointIds are not only unique by-device, but unique by device-and-application. One should think of them as a sort of “persistent sockets”. Different applications will want to open their own endpoints, and each device will obviously have different endpoints running. And there might even be some endpoints running to support OS-level things (think of an iroh-powered “airdrop” integrated into gnome or so). It seems natural to me to use different lexicon authorities for different applications.
And then I have some more “nitpicky” remarks:
Why wrap the EndpointId in `did:key:`? I’d just go with the standard hex-encoded EndpointId for maximum simplicity. (This is also directly supported by iroh’s `EndpointId::from_str` APIs and the format used to display EndpointIds.)
It might be worth noting that the `iss` did:plc/did:web needs to be checked against the authority of the repository that the record is in. Or at least against the DID document’s `alsoKnownAs`. Or specifically, in the “Repo-level attestation” section it might be a good idea to note that the DID in the record needs to be matched against the DID of the repository. (Another option would be to remove that DID from the repo, and only add back the “implied DID” during signing for device-level attestation. I’m not sure if that’s better yet.)
Sorry for not directly addressing the open questions at the end of the article. I think most of them end up being application-level concerns.
FWIW I’d love to land on “rough consensus and running code” by collaborating on an iroh-atp crate that defines an `EndpointAtpAttestation` struct that can be encoded, signed, and verified, as well as a function that resolves atproto DID/handle → Set of EndpointAddr.
nbf was a stretch and I don’t think there’s any compelling reason to include it, but exp hedges against replay attacks against peers who aren’t up-to-date with the status of the relevant repository/repositories, or against peers that don’t use the repository as the source-of-truth at all, and only exchange device attestations directly after initating a connection. It seems natural to me that device attestations are time-bound, but if it is less valuable to applications than I originally thought it would be, then the exp field could be defined as entirely optional to include, but should be strictly honored whenever it is included.
Voicing support for including the peer’s chosen RelayUrl alongside their EndpointId, most likely as a plain URL string (https://usw1-1.relay.n0.iroh-canary.iroh.link/). I have strong feelings against including SocketAddr or CustomAddr in the device attestation, even in the circumstance of an attestation being published to a permissioned repo. To me, it’s not worth advertising a peer’s IP, as opposed to the more privacy-preserving and equally functional RelayUrl.
When necessary, applications would be allowed to include a set of accepted ALPNs in the device attestation, rather than just a single one. It would also be allowed to advertise many different individual endpoints for the same device, following your note of the device-and-application uniqueness constraint.
I chose the did:key form as it felt more in line with the existing atproto plumbing than the plain hex format, and the use of Multicodec to include key type metadata seems more resilient than an opaque 32-byte string. Iroh only uses Ed25519 keys at the time of writing, but it would be preferable for the syntax to allow for future changes. I think the self-describing nature of the Multikey format holds its own weight, but this isn’t a load-bearing part of the proposal, and could be swapped out if consensus dictates as such.
Voicing support for making explicit reference to checking the iss against the DID of the repository the record is being fetched from; I thought I included something along those lines in the proposal.
When we were deploying UCAN on end-user devices a long time ago (well, 4 years?), we’ve struggled with device clocks being wildly out of sync. At that time, we used short-lived UCANs that expired after 60s. We’d basically fetch a new UCAN and immediately think it’s outdated because of the device’s clock.
Applications that want to do one-off connections by-atproto-handle should probably only accept the first valid connection and then rotate their EndpointId once that was established. I’d expect that applications that want to use the expiry will end up switching away from this once they hit roadblocks with device clocks.
It’s generally much easier to have proper time-based verification when you have control over the clock (like is the case with JWTs that are verified on correctly configured backend services only).
That’s fair You’re losing some generality by restricting it this way, e.g. there are some iroh Endpoints that run on publicly-reachable IP VPSes that have `RelayMode::Disabled` configured. Those can only be reached by their public IP and not via any relay.
But FWIW, this can be changed in the future anyways.
About the set of ALPNs and `did:key:` multiformat:
I’d much rather have a version identifier on the envelope-level.
This way we can keep the first version as simple as possible (this is also good for security!). Then later when we realize we want another key type, we bump the version of the whole “iroh-with-atp-envelope”.
We’d want this kind of version-ability anyways. Adding the multiformat on top is not necessary for making it possible to migrate to different asymmetric crypto. (I realize that all of atproto, UCAN and other specifications are full of these… FWIW this is the feedback I’d like to give to new protocols, but I do realize that it might look somewhat out-of-line.)
I’ll make a case for the DID Key EndpointId encoding. DASL takes the position that:
Extensibility is important for long-lived distributed systems, because the world will happen and you will need to change. But introducing optionality reduces interoperability and increases cost of both implementation and adoption. So rather than require support for many options now, we have extension points now but deliberately don’t use their full range.
And I would agree to that position for this proposal: the EndpointId format should be self-describing, but limited to a small set of well-established and broadly compatible invariants that can expand with community consensus/adoption. Just to compare the size of the possible formats, for an Ed25519 public key encoded as CBOR:
h'…' (raw bytes): 34 bytes
h'ed01…' (raw multikey): 36 bytes
"…" (hex string): 66 bytes
"z6Mk…" (Base58 multikey string): 50 bytes
"did:key:z6Mk…" (DID Key): 58 bytes
My personal preference would be the DID Key, as it best fits into existing atproto tooling and libraries. I suppose this is a matter of which ecosystem’s preferences influence the design of the proposal more: atproto or iroh.
CC @robin.berjon.com@iame.li I’d be curious on your thoughts about the particulars of encoding for this proposal.
I’ve been working on an idea for an E2EE systems using AT Protocol DIDs as identity, and it shares the problem that you tackled with device attestation. Instead of calling them device attestation, I framed it as delegating ATProto identity to per-device key pair.
I chose to use a self-signed X509 certificate for the delegated identity key pair. This simplified using the key in different contexts like s/mime, tls, and our E2EE protocol.
Mutual authentication (you refer to as Replay resistance) of the device key pair with the AT Protocol DID happens in a similar way to your proposal:
The self-signed certificate (signed by the device key) identifies the ATProto DID as the CN, binding the device key to this AT Proto DID
The AT Protocol PDS stores this certificate at:
`at://{did}/at.atsms.x509/{cert serial}`, binding the ATProto DID to this device key
The certificate itself handles encoding the key in a standard way (ASN.1):
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
46:a1:79:f2:2e:6a:1e:4b:3c:69:7f:d8:1f:7b:3c:ea
Signature Algorithm: ecdsa-with-SHA256
Issuer: CN=did:plc:gbkt44wmk7k3h3dm2dlqhcoj
Validity
Not Before: Apr 8 05:00:20 2026 GMT
Not After : Apr 7 05:00:20 2036 GMT
Subject: CN=did:plc:gbkt44wmk7k3h3dm2dlqhcoj
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub: ...
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Key Usage: critical
Digital Signature
X509v3 Subject Alternative Name:
DNS:aib0b.bsky.social, URI:at://{did}/at.atsms.x509/46a179f22e6a1e4b3c697fd81f7b3cea
Signature Algorithm: ecdsa-with-SHA256
Signature Value: ...
I’m not suggesting you use this, just offering it as food for thought. It is a generalized way to delegate ATProto DID identity to a per-device key pair that can be used across different protocols.
Making an open call for interest in forming a Lexicon Community working group to handle this proposal’s efforts, focused on producing the two relevant deliverables:
Lexicon schemas under the namespace community.lexicon.iroh.* (or similar), and
a final draft specification for the peer discovery mechanisms and trust model
As well as any other application-level concerns that might be in scope for this WG, under the overall umbrella of “atproto + iroh”.
Love it! I chatted with b5 & @matheus23.com about something similar at local first conf
I don’t have much to add that you all haven’t already gotten into.
I’d argue for using the Iroh EndpointId without translating into did:key and then giving ourselves some flexibility for upgrades in the envelope format. Imo, it makes sense for the record to reference both sides in their “native” format. So the Iroh endpoint gets referenced in the Iroh manner, and the iss (the repo), gets referenced in the atproto manner.
If the Iroh crew is interested, this could even go under computer.iroh.*
While I think it’s worth standardizing an envelope format so we can all share code & not accidentally get the verification code wrong, I don’t think applications would actually want to be co-located on the same lexicon.
Say on my device I’ve installed some apps like a file-share-via-iroh-atp, a chat-via-iroh-atp and a videocalls-via-iroh-atp. These apps will want to bring online their endpoints in their own cadence, have their own configurations for the endpoint and will use separate EndpointIds for each.
I thus don’t think it makes a lot of sense for them to live under the same namespace. Each app will want to have their own.
I do think that eventually you might have e.g. two file-share-via-iroh-atp apps that will want to interop. (E.g. this kinda exists today with multiple apps supporting the format/protocol we started with `sendme`) Perhaps at that point it would then make sense to have a steward for dialing e.g. `computer.iroh.sendme` or something like that.
But I’d be against a general iroh-atp namespace, I don’t think that’d be a fitting design.
I think permissioned data spaces should be able to handle this. Each space can have its own computer.iroh.whatever collection, so apps wouldn’t need to deal with records from other apps; they’d each get their own namespace even though they’d be using the same collection. (As a bonus, the EndpointId and EndpointAddr wouldn’t be publicly visible.)
Another option could be to add something like a clientId field to the lexicon, although that’s maybe a little worse (clients would need to list all records and filter down, each peer may be using a different client for the same app, etc).
Edit, just remembered re:
Lexicons can embed other lexicons; publishing a computer.iroh.whatever lexicon doesn’t mean apps are forced to use that collection. So even when using public data, apps are free to choose whatever namespace they want, and they’d still get the benefit of whatever tooling sprouts up around any lexicons we come up with here.
I think the use case of iroh and atproto for social P2P applications would benefit the most from this proposal by establishing a shared, generic identity layer. Yes, developers are free to specify their own methods of binding DID-to-Iroh-endpoint, but apps that use the generic identity layer have the potential to benefit more than those that don’t, if the generic identity layer is adopted as community consensus. This proposal was intended to foster consensus on a common method of combining iroh and atproto, one that could peacefully co-exist with apps doing their own thing.
You might also consider making the sub an AT URI.
(eg. at://{did}/some.lexicon/key_id)
It makes the iss redundant, but also also makes it obvious that this sub is a delegated ATProto ID.
It doesn’t answer the question of how to encode contents of the delegated identity key material, but it allows ATProto lexicon to drive that logic. If done right, this could be a common way to delegate AT Proto identity across other protocols beyond just iroh. Also, this approach allows the app to declare which lexicon is used for the delegated identity rather than requiring agreement ahead of time on one that is baked into some spec.
My response about the format for sub field was influenced by misreading your earlier comment about generic identity layer. Since the iroh Endpoint ID is really just an Ed25519 key pair, I was initially thinking about a more generic way to specify it such that it could also be used in contexts outside of iroh.
But, after bit more thought, I don’t think what I proposed is something to strive for iroh Endpoint IDs. I see now that the original intent of your comment was an iroh-specific “generic identity layer”, and I agree. I lean in favor of what was proposed by @dholms.xyz (I’d even ditch sub for something that more obviously indicates it is an iroh endpoint ID.)
Yes, agreed. I think permissioned spaces can solve the per-app signalling if they desire as @jakelazaroff.com mentioned, but having a generic identity layer by default benefits everyone and should be the explicit goal of the working group. I’m impartial to the actual namespace.
Seconding this as well, seems like the right call to me.