// Package pkarr implements the pkarr (https://pkarr.org) signed DNS packet // format used by iroh for endpoint discovery. // // Wire format: <32-byte public key><64-byte signature><8-byte big-endian // microsecond timestamp><DNS wire packet>. The signature covers the BEP-0044 // signable bytes derived from the timestamp and the encoded DNS packet. The DNS // packet must be at most 1000 bytes; the total signed packet is at most 1104. // // It is a port of iroh-dns/src/pkarr.rs.
package pkarr import ( ) const ( // maxDNSPacketSize is the maximum size of the encoded DNS packet within a // signed packet. maxDNSPacketSize = 1000 // headerSize is 32 (public key) + 64 (signature) + 8 (timestamp). headerSize = 104 // MaxBytes is the maximum total size of a serialized signed packet. MaxBytes = headerSize + maxDNSPacketSize ) // Errors returned by this package. var ( ErrPacketTooLarge = errors.New("pkarr: DNS packet too large") ErrTooShort = errors.New("pkarr: signed packet too short") ErrTooLarge = errors.New("pkarr: signed packet too large") ErrSignature = errors.New("pkarr: invalid signature") ErrDNS = errors.New("pkarr: DNS decoding error") ErrInvalidKey = errors.New("pkarr: invalid public key") ) // SignedPacket is a signed DNS packet in the pkarr format. It is immutable; all // accessors derive their result from the stored wire bytes. type SignedPacket struct { bytes []byte } // FromTxtStrings creates a signed packet containing one TXT record per value, // all under the single DNS name relative to the signer's z-base-32 public key // (the common case, e.g. name "_iroh"). ttl is the record TTL in seconds. func ( key.SecretKey, string, []string, uint32) (*SignedPacket, error) { := .Public() := .EndpointID().Z32() := normalizeName(, ) , := buildTxtPacket(, , ) if != nil { return nil, fmt.Errorf("%w: %v", ErrDNS, ) } if len() > maxDNSPacketSize { return nil, fmt.Errorf("%w: %d bytes (max %d)", ErrPacketTooLarge, len(), maxDNSPacketSize) } := Now() := .Sign(signable(.Micros(), )) := .Bytes() := .Bytes() := make([]byte, 0, headerSize+len()) = append(, [:]...) = append(, [:]...) = append(, .beBytes()...) = append(, ...) return &SignedPacket{bytes: }, nil } // FromBytes parses and verifies a signed packet from its wire representation. func ( []byte) (*SignedPacket, error) { if := checkLen(); != nil { return nil, } , := key.PublicKeyFromSlice([:32]) if != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidKey, ) } , := key.SignatureFromSlice([32:96]) if != nil { return nil, fmt.Errorf("%w: %v", ErrSignature, ) } var [8]byte copy([:], [96:104]) := timestampFromBE() := [104:] if := .Verify(signable(.Micros(), ), ); != nil { return nil, fmt.Errorf("%w: %v", ErrSignature, ) } if , := parsePacket(); != nil { return nil, fmt.Errorf("%w: %v", ErrDNS, ) } return &SignedPacket{bytes: bytes.Clone()}, nil } // FromBytesUnchecked parses a signed packet without verifying its signature. It // still validates the minimum length and that the DNS packet parses. func ( []byte) (*SignedPacket, error) { if := checkLen(); != nil { return nil, } if , := parsePacket([104:]); != nil { return nil, fmt.Errorf("%w: %v", ErrDNS, ) } return &SignedPacket{bytes: bytes.Clone()}, nil } // FromRelayPayload reconstructs a signed packet from a public key and a relay // payload (signature + timestamp + DNS packet, i.e. everything after the key). func ( key.PublicKey, []byte) (*SignedPacket, error) { := .Bytes() := make([]byte, 0, 32+len()) = append(, [:]...) = append(, ...) return FromBytes() } // Bytes returns the full serialized wire bytes. The result must not be mutated. func ( *SignedPacket) () []byte { return .bytes } // RelayPayload returns the relay payload: everything after the public key. func ( *SignedPacket) () []byte { return bytes.Clone(.bytes[32:]) } // PublicKey returns the signer's public key. func ( *SignedPacket) () key.PublicKey { , := key.PublicKeyFromSlice(.bytes[:32]) return } // Signature returns the packet signature. func ( *SignedPacket) () key.Signature { , := key.SignatureFromSlice(.bytes[32:96]) return } // Timestamp returns the packet timestamp. func ( *SignedPacket) () Timestamp { var [8]byte copy([:], .bytes[96:104]) return timestampFromBE() } // EncodedPacket returns the encoded DNS packet bytes. func ( *SignedPacket) () []byte { return .bytes[104:] } // MoreRecentThan reports whether p is more recent than other, breaking ties on // equal timestamps by comparing the encoded DNS packets. func ( *SignedPacket) ( *SignedPacket) bool { if .Timestamp() == .Timestamp() { return bytes.Compare(.EncodedPacket(), .EncodedPacket()) > 0 } return .Timestamp().Micros() > .Timestamp().Micros() } // TxtRecords returns the TXT string values under the given DNS name (normalized // relative to the signer's z-base-32 public key). func ( *SignedPacket) ( string) []string { := .PublicKey().EndpointID().Z32() := normalizeName(, ) , := parsePacket(.EncodedPacket()) if != nil { return nil } var []string for , := range { := strings.TrimSuffix(.name, ".") if == { = append(, .txt) } else if , := withoutZone(, ); && == strings.TrimSuffix(, ".") { = append(, .txt) } } return } // AllTxtRecords returns all TXT records as (name-relative-to-origin, value) pairs. func ( *SignedPacket) () [][2]string { := .PublicKey().EndpointID().Z32() , := parsePacket(.EncodedPacket()) if != nil { return nil } var [][2]string for , := range { := strings.TrimSuffix(.name, ".") , := withoutZone(, ) = append(, [2]string{, .txt}) } return } func checkLen( []byte) error { if len() < headerSize { return fmt.Errorf("%w: %d bytes (min %d)", ErrTooShort, len(), headerSize) } if len() > MaxBytes { return fmt.Errorf("%w: %d bytes (max %d)", ErrTooLarge, len(), MaxBytes) } return nil } // signable constructs the BEP-0044 signable bytes: "3:seqi<ts>e1:v<len>:" + v. func signable( uint64, []byte) []byte { := "3:seqi" + strconv.FormatUint(, 10) + "e1:v" + strconv.Itoa(len()) + ":" := make([]byte, 0, len()+len()) = append(, ...) = append(, ...) return } // normalizeName normalizes a DNS name relative to the pkarr origin (the // z-base-32 public key). A trailing dot is stripped first. func normalizeName(, string) string { = strings.TrimSuffix(, ".") := strings.Split(, ".") := "" if len() > 0 { = [len()-1] } if == { return } if == "@" || == "" { return } return + "." + } // withoutZone strips a trailing ".<origin>" (or exactly "<origin>") from name, // returning the relative part and whether name was within the zone. func withoutZone(, string) (string, bool) { if == { return "", true } if := "." + ; strings.HasSuffix(, ) { return strings.TrimSuffix(, ), true } return , false } // Timestamp is a pkarr timestamp in microseconds since the UNIX epoch. type Timestamp uint64 // lastTimestamp tracks the last value returned by Now for strict monotonicity. var lastTimestamp atomic.Uint64 // Now returns a strictly monotonic timestamp: greater than any previous call, // even if the system clock moves backward. func () Timestamp { := uint64(time.Now().UnixMicro()) for { := lastTimestamp.Load() := if <= { = + 1 } if lastTimestamp.CompareAndSwap(, ) { return Timestamp() } } } // TimestampFromMicros creates a timestamp from a raw microseconds value. func ( uint64) Timestamp { return Timestamp() } // Micros returns the raw microseconds value. func ( Timestamp) () uint64 { return uint64() } func ( Timestamp) () []byte { var [8]byte := uint64() for := 7; >= 0; -- { [] = byte() >>= 8 } return [:] } func timestampFromBE( [8]byte) Timestamp { var uint64 for , := range { = <<8 | uint64() } return Timestamp() } // SignedPacketBuildError and SignedPacketVerifyError sentinel checks are exposed // via errors.Is against the package error values above. var _ = dnsmessage.TypeTXT