ipanon

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

README

ipanon

IP address anonymization and pseudonymization for Go. Zero dependencies, stdlib only.

go get github.com/hosted-status-page/hsp-ip-anonymizer
import ipanon "github.com/hosted-status-page/hsp-ip-anonymizer"

anon, err := ipanon.PolicyAnalytics().Build()
if err != nil {
    return err
}
s, err := anon.AnonymizeString(netip.MustParseAddr("192.168.42.123"))
// "192.168.42.0"

What's here

Technique Type Output for 192.168.42.123 Use it for
Dropper discard "" Anything where you only need derived fields
Truncator mask host bits 192.168.42.0 Web analytics, coarse geolocation
Hasher HMAC-SHA256 a1b2c3… (128-bit token) Distinct-source counts with a static key
Pseudonymizer HMAC under a rotating key a1b2c3…, different tomorrow Rate limiting, abuse detection, per-day counts
CryptoPAn prefix-preserving, reversible another valid address, same subnet structure Netflow, subnet-level abuse analysis, trace sharing
kanon.Generalizer adaptive prefix widening 192.168.32.0/20 Datasets where a fixed prefix protects unevenly

Plus: httpanon (middleware with trusted-proxy client IP extraction), sloganon (a slog.Handler wrapper), redact (rewrite addresses in arbitrary log text), and a cmd/ipanon CLI for batch work.

Choosing

Start from what you actually need to compute, not from what sounds most private.

Need Drop Truncate /24 Hash Pseudonym Crypto-PAn k-anon
Count distinct sources, lifetime no approximate yes no yes approximate
Count distinct sources, per day no approximate yes yes yes approximate
Geolocate to country derive first usually no no never¹ usually
Rate limit per source no per /24 yes within period yes per bucket
Recognize a source across days no per /24 yes no yes no
See that two hosts share a subnet no no no no yes partly
Re-identify during an incident no no no no with key no
Genuinely anonymous under GDPR yes no no no no no

¹ Crypto-PAn output is a syntactically valid address that may belong to a real, unrelated network. Never geolocate it, feed it to a reputation service, put it in a firewall rule, or cite it in an abuse report.

For most web analytics, PolicyAnalytics() (truncation) is the right answer. For anything that needs to recognize a repeat source, PolicyRateLimit(key).

Geolocate before you anonymize

Truncating and then geolocating loses precision for no privacy gain — the full address was processed either way. Derive what you need first, store only the derived fields:

mw := httpanon.Handler(anon,
    httpanon.WithTrustedProxies(netip.MustParsePrefix("10.0.0.0/8")),
    httpanon.WithRewriteRemoteAddr(true),
    httpanon.WithOnRawAddr(func(r *http.Request, addr netip.Addr) {
        // Full address is available here, and only here.
        ctx := geo.Annotate(r.Context(), addr) // country, ASN
        *r = *r.WithContext(ctx)
    }),
)

WithRewriteRemoteAddr(true) matters more than it looks: without it the raw address stays on http.Request.RemoteAddr, where every downstream logger, panic handler, and third-party middleware will keep recording it.

X-Forwarded-For is attacker-controlled

httpanon ignores forwarding headers unless you configure trusted proxies, and then walks the chain right to left. Reading the leftmost entry — the most common implementation — lets any client choose its own apparent address and evade every per-address rate limit and ban you have.

httpanon.WithTrustedProxies(
    netip.MustParsePrefix("10.0.0.0/8"),
    // If you're behind a CDN, use its published ranges and refresh them.
)

Key handling

key, err := ipanon.GenerateKey()          // 32 bytes from crypto/rand
key, err := ipanon.KeyFromEnv("IPANON_KEY") // hex, for containers

Or from the CLI: ipanon -genkey.

Pseudonymizer derives a fresh key per period from a master key with HKDF, so there is no address-to-token mapping table to secure, replicate, or disclose. Every replica computes the same token independently, and restarts change nothing.

A leaked key is the whole ballgame for the keyed techniques: the IPv4 space is 2³², so anyone holding the key can rebuild the complete reverse lookup table in minutes. Rotation bounds how much history one leaked epoch key exposes. See docs/COMPLIANCE.md.

CLI

ipanon -technique truncate access.log > access.anon.log
tail -f access.log | ipanon -technique pseudonym -key-env IPANON_KEY
ipanon -genkey

Performance

Apple M2 Max, Go 1.26:

Operation ns/op allocs/op
Truncator.AnonymizeAddr 23 0
Hasher / Pseudonymizer token 255 4
kanon.Generalizer.Observe 179 0
Crypto-PAn IPv4 (32 AES blocks) 346 1
Crypto-PAn IPv6 (128 AES blocks) 1252 1
redact one log line, one address 713 20

Crypto-PAn on IPv6 is ~55× the cost of truncation. Fine per HTTP request; measure before putting it in a packet-rate path. The keyed techniques pool HMAC state per key, so rotation costs one re-derivation per period rather than one per call.

Correctness

CryptoPAn is verified against the 71 IPv4 and 5 IPv6 reference vectors from the original Xu/Fan/Ammar/Moon distribution, so its output is interoperable with other Crypto-PAn implementations. Property tests cover prefix preservation, bijectivity over a full /16, and round-tripping.

go test -race ./...
go test -fuzz FuzzRedactNeverPanics ./redact

Two implementation details that matter

Addresses are masked, never string-sliced. Truncation goes through netip.Prefix.Masked(), and every address is normalized with Unmap() first. Splitting on : and rejoining is a common shortcut that produces wrong prefixes for compressed forms like 2001:db8::1; normalizing first is what makes a dual-stack listener and an IPv4 listener yield the same value for the same client, instead of quietly double-counting them.

Tokens use HMAC-SHA256 over the canonical address bytes. Not sha256(secret + text). Two reasons. HMAC is the standard keyed PRF and has no length-extension footgun if the scheme is later extended. And hashing the packed 4- or 16-byte form rather than the textual form means 192.0.2.1 and ::ffff:192.0.2.1 produce one token, not two.

LegacySaltedSHA256 implements the older secret-prefix construction, and exists solely so you can keep matching values you have already stored while migrating.

Scope

Not legal advice. This library implements techniques; whether your use of them satisfies a particular regulator is a question for your counsel.

License

Apache 2.0. See LICENSE.

Documentation

Overview

Package ipanon implements IP address anonymization and pseudonymization techniques for privacy-preserving logging and analytics.

Anonymization is not one thing

The techniques in this package sit on a spectrum, and the distinction is legally significant. Under the GDPR, data is only "anonymous" (and therefore out of scope, Recital 26) when re-identification is not reasonably possible by any means, for anyone, including the controller. Most of what the industry calls "IP anonymization" does not clear that bar:

  • Dropper produces no data at all. Genuinely anonymous.
  • Truncator zeroes host bits. A /24 collapses at most 256 addresses, so a truncated value combined with a timestamp and other request attributes can often still single out a person. Treat as pseudonymization.
  • Hasher is keyed and one-way, but the IPv4 keyspace is only 2^32: anyone who obtains the key can rebuild the full lookup table offline in minutes. Pseudonymization, and only for as long as the key stays secret.
  • Pseudonymizer is a Hasher whose key rotates on a schedule, so tokens cannot be linked across periods. Pseudonymization with a bounded blast radius.
  • CryptoPAn is prefix-preserving and mathematically invertible by the key holder. It is pseudonymization by construction and never anonymization.

See docs/COMPLIANCE.md for the full treatment. Nothing in this package is legal advice.

Choosing a technique

If you only need country- or region-level analytics, geolocate first and then drop the address entirely. If you need to recognize a repeat source over time, use Pseudonymizer with a rotation period matched to your retention window. If you need subnet structure preserved for abuse or netflow analysis, use CryptoPAn. Use Truncator when you need a value that is still routable -looking and coarsely geolocatable.

Concurrency

Every anonymizer in this package is safe for concurrent use by multiple goroutines once constructed.

Index

Constants

View Source
const (
	// RotateDaily gives per-day linkability: you can count distinct sources
	// within a day but cannot follow one across midnight. The usual choice for
	// analytics.
	RotateDaily = 24 * time.Hour
	// RotateWeekly suits abuse detection that needs a longer memory.
	RotateWeekly = 7 * 24 * time.Hour
	// RotateHourly is aggressive; useful for high-sensitivity logs where you
	// only need to correlate requests within a single session.
	RotateHourly = time.Hour
)

Common rotation periods for NewRotatingKeys.

View Source
const CryptoPAnKeyLen = 32

CryptoPAnKeyLen is the key length required by NewCryptoPAn: 16 bytes of AES-128 key followed by 16 bytes from which the pad is derived.

View Source
const DefaultOutputBits = 128

DefaultOutputBits is the token width used unless overridden.

View Source
const KeyLen = 32

KeyLen is the length in bytes of the keys used by this package.

View Source
const MinOutputBits = 64

MinOutputBits is the smallest token width this package will produce.

Below 64 bits, collisions between distinct addresses stop being negligible: by the birthday bound, a 32-bit token collides after roughly 65,000 distinct addresses, which would silently merge unrelated users into one apparent identity and corrupt exactly the distinct-count metrics the token exists to support. Truncating a token does not add privacy in any case, because the keyspace being protected is the address space, not the digest.

Variables

View Source
var (
	// ErrInvalidAddr reports an address that is not valid, such as the zero
	// netip.Addr or a value that failed to parse.
	ErrInvalidAddr = errors.New("ipanon: invalid IP address")

	// ErrNotReversible reports an attempt to recover an original address from
	// a technique that is genuinely one-way.
	ErrNotReversible = errors.New("ipanon: technique is not reversible")

	// ErrInvalidKey reports a key that is missing, the wrong length, or
	// otherwise unusable.
	ErrInvalidKey = errors.New("ipanon: invalid key")

	// ErrInvalidConfig reports a configuration that cannot produce a working
	// anonymizer, such as a prefix length outside the valid range.
	ErrInvalidConfig = errors.New("ipanon: invalid configuration")
)

Errors returned by this package. Callers should test with errors.Is.

Functions

func GenerateKey

func GenerateKey() ([]byte, error)

GenerateKey returns a new random key of KeyLen bytes, suitable for any technique in this package.

Store the result in your secret manager. A key that is lost cannot be recovered, and every value derived from it becomes unlinkable to newly anonymized data; a key that leaks retroactively de-pseudonymizes every value ever derived from it.

func KeyFromEnv

func KeyFromEnv(name string) ([]byte, error)

KeyFromEnv reads a hex-encoded key of at least KeyLen bytes from the named environment variable.

This is a convenience for containerized deployments. It is not an endorsement of environment variables as a secret store: they are visible in process listings, inherited by child processes, and frequently captured by crash reporters. A secret manager is better.

func LegacySaltedSHA256 deprecated

func LegacySaltedSHA256(salt, ip string) string

LegacySaltedSHA256 reproduces the sha256(salt + ip.String()) construction found throughout deployed code and older guidance.

It exists so that you can keep matching values you have already stored while you migrate, and for no other reason.

Deprecated: this construction hashes the textual form of the address, so "192.0.2.1" and "::ffff:192.0.2.1" produce different tokens for the same client, and it uses an ad hoc secret-prefix MAC rather than HMAC. Use NewHasher or NewPseudonymizer for anything new.

func Normalize

func Normalize(addr netip.Addr) (netip.Addr, error)

Normalize returns addr in the canonical form used throughout this package.

It unmaps IPv4-in-IPv6 addresses, so ::ffff:192.0.2.1 is treated as the IPv4 address 192.0.2.1 rather than as a 128-bit address. This matters: without it, an IPv4 client arriving over a dual-stack listener would be truncated with IPv6 rules and produce a different value than the same client on an IPv4 listener.

It also strips any zone identifier, since a zone is a local interface name with no meaning outside the host that observed it.

Normalize reports ErrInvalidAddr for the zero Addr.

func ParseAddr

func ParseAddr(s string) (netip.Addr, error)

ParseAddr parses s as an IP address and normalizes it. It accepts a bare address ("192.0.2.1") or an address with a port ("192.0.2.1:443", "[2001:db8::1]:443"), which makes it convenient for http.Request.RemoteAddr.

Types

type Action

type Action uint8

Action says what an anonymizer should do with an address of a given Scope.

const (
	// ActionAnonymize applies the configured technique. The default.
	ActionAnonymize Action = iota
	// ActionPassThrough returns the address unchanged. Use it only for scopes
	// that cannot identify an external person, such as loopback.
	ActionPassThrough
	// ActionDrop discards the address entirely.
	ActionDrop
)

The available actions. The zero value anonymizes, so a zero ScopeRules is the safe default: everything gets anonymized.

type AddrAnonymizer

type AddrAnonymizer interface {
	Anonymizer
	AnonymizeAddr(addr netip.Addr) (netip.Addr, error)
}

AddrAnonymizer is implemented by techniques whose output is itself an IP address, so the result can be stored in an inet column, geolocated, or fed into further processing.

The invalid zero netip.Addr is a valid result and means the address was deliberately discarded.

type Anonymizer

type Anonymizer interface {
	AnonymizeString(addr netip.Addr) (string, error)
}

Anonymizer maps an address to a stable, privacy-reduced string form.

Implementations must be safe for concurrent use. The empty string is a valid result and means the address was deliberately discarded.

type CryptoPAn

type CryptoPAn struct {
	// contains filtered or unexported fields
}

CryptoPAn implements the Crypto-PAn prefix-preserving address anonymization scheme of Xu, Fan, Ammar, and Moon.

What prefix-preserving means

If two addresses share their first n bits, their anonymized forms also share their first n bits, and if they differ at bit n, so do the outputs. The hierarchy of the address space survives intact: a /24 stays a /24, subnets stay nested inside their parent networks, and two hosts in the same network are still visibly in the same network afterwards.

That is what makes it the right tool when the structure is the signal. Truncation destroys the host part, so you cannot tell two machines in a subnet apart; hashing destroys the structure, so you cannot tell that two hosts are related at all. Crypto-PAn keeps both, which is why it is the standard for sharing netflow and packet traces, and why it works for abuse analysis that reasons about "this whole /22 started scanning us".

It is reversible, therefore it is pseudonymization

The mapping is a bijection, computable in both directions by anyone holding the key. Crypto-PAn output is personal data, full stop. Protect the key as carefully as you would protect the original addresses, and do not describe data anonymized this way as anonymous in a privacy notice.

Prefix preservation carries its own leak, independent of the key. The output preserves the frequency distribution of prefixes, so an adversary who knows roughly how your traffic is distributed across networks can match large blocks against public routing data without ever attacking the key. This is well documented in the literature on trace anonymization; it is a property of the scheme, not a flaw in this implementation. If you are publishing traces rather than protecting your own logs, read up before relying on it.

Cost

One AES block encryption per address bit: 32 for IPv4, 128 for IPv6. That makes it roughly two orders of magnitude more expensive than truncation. Fine per HTTP request, worth measuring before putting it in a packet-rate path.

A CryptoPAn is safe for concurrent use.

func NewCryptoPAn

func NewCryptoPAn(key []byte) (*CryptoPAn, error)

NewCryptoPAn returns a CryptoPAn using key, which must be exactly CryptoPAnKeyLen bytes. The first half is the AES-128 key; the second half is encrypted under it to produce the pad, following the reference implementation, so that keys are interchangeable with other Crypto-PAn implementations.

The returned value does not implement Reversible. Use NewReversibleCryptoPAn when you deliberately want the ability to recover original addresses, so that the capability is visible at the call site and in code review rather than being available by accident.

func (*CryptoPAn) AnonymizeAddr

func (c *CryptoPAn) AnonymizeAddr(addr netip.Addr) (netip.Addr, error)

AnonymizeAddr implements AddrAnonymizer.

The result is a syntactically valid address of the same family, and it is indistinguishable from a real one. It may well collide with space that genuinely belongs to somebody else, so never feed Crypto-PAn output to a geolocation lookup, a reputation service, a firewall rule, or an abuse report: you would be making claims about an unrelated network.

func (*CryptoPAn) AnonymizePrefix

func (c *CryptoPAn) AnonymizePrefix(p netip.Prefix) (netip.Prefix, error)

AnonymizePrefix anonymizes a network rather than a single address, keeping the prefix length. Because the scheme is prefix-preserving, this is the network that every address inside p maps into.

func (*CryptoPAn) AnonymizeString

func (c *CryptoPAn) AnonymizeString(addr netip.Addr) (string, error)

AnonymizeString implements Anonymizer.

func (*CryptoPAn) Kind

func (c *CryptoPAn) Kind() Kind

Kind returns KindCryptoPAn.

func (*CryptoPAn) WithScopeRules

func (c *CryptoPAn) WithScopeRules(r ScopeRules) *CryptoPAn

WithScopeRules returns a copy of c that applies the given per-scope handling.

type Dropper

type Dropper struct {
	// contains filtered or unexported fields
}

Dropper discards the address entirely. It is the only technique in this package that produces genuinely anonymous output, because it produces no output: there is nothing to correlate, nothing to brute-force, no key to leak, and nothing to hand over in response to a subject access request.

Dropping is not the same as not collecting. If the address reaches your process it has been processed, and if it reaches a proxy access log or a crash dump on the way, dropping it downstream does not help. Check the whole path.

The usual pattern is to derive what you actually need from the full address first and drop only the address:

country := geo.Country(addr) // your geolocation provider
asn := geo.ASN(addr)
log.Info("request", "country", country, "asn", asn) // no address at all

A Dropper exists as a type, rather than as the absence of one, so that dropping can be selected through the same Policy machinery as every other technique and switched on per environment without changing call sites.

func NewDropper

func NewDropper() *Dropper

NewDropper returns a Dropper.

func (*Dropper) AnonymizeAddr

func (d *Dropper) AnonymizeAddr(addr netip.Addr) (netip.Addr, error)

AnonymizeAddr implements AddrAnonymizer, returning the invalid zero netip.Addr for any address the scope rules do not exempt.

func (*Dropper) AnonymizeString

func (d *Dropper) AnonymizeString(addr netip.Addr) (string, error)

AnonymizeString implements Anonymizer, returning the empty string for any address the scope rules do not exempt.

func (*Dropper) Kind

func (d *Dropper) Kind() Kind

Kind returns KindDrop.

func (*Dropper) WithScopeRules

func (d *Dropper) WithScopeRules(r ScopeRules) *Dropper

WithScopeRules returns a copy of d that applies the given per-scope handling. This is how you keep internal addresses in logs while discarding every external one:

dropper := ipanon.NewDropper().WithScopeRules(ipanon.PassThroughInternal())

type Encoding

type Encoding uint8

Encoding selects the textual representation of a token.

const (
	// EncodingHex is lowercase hexadecimal. The default: universally readable,
	// greppable, and safe in every log format and column type.
	EncodingHex Encoding = iota
	// EncodingBase32 is unpadded, lowercase RFC 4648 base32. Shorter than hex
	// and still case-insensitive, which suits systems that fold case.
	EncodingBase32
	// EncodingBase64URL is unpadded RFC 4648 URL-safe base64. The most compact;
	// avoid it where the consumer folds case or splits on "-".
	EncodingBase64URL
)

The available token encodings.

type Hasher

type Hasher struct {
	// contains filtered or unexported fields
}

Hasher replaces an address with a keyed one-way token.

Unlike truncation, hashing preserves distinctness: two different addresses almost certainly produce two different tokens, so distinct-source counts and per-source rate limits keep working while the address itself is not stored.

Why keyed, and why this is not anonymization

The familiar formulation of this technique is sha256(salt + ip). That is weaker than it looks on two counts. First, a bare hash of an IPv4 address is not protection at all: the entire IPv4 space is 2^32 addresses, so anyone can enumerate it and build a complete reverse lookup table on a laptop. Second, prepending a secret to a SHA-256 input is a homemade MAC construction that invites length-extension mistakes when the scheme is later extended.

This implementation uses HMAC-SHA256, the standard keyed pseudorandom function, which addresses the second point. It cannot address the first: the security of the token rests entirely on the key staying secret. If the key leaks, every token ever produced under it can be reversed by brute force in minutes. That is why this is pseudonymization, not anonymization, and why you should prefer Pseudonymizer, whose rotating key bounds how much history a single leaked key exposes.

A Hasher is safe for concurrent use.

func NewHasher

func NewHasher(keys KeyProvider) (*Hasher, error)

NewHasher returns a Hasher that derives tokens from the key supplied by keys, with DefaultOutputBits of output in EncodingHex.

Pass a StaticKey for tokens that stay linkable indefinitely. Pass RotatingKeys and you have built a Pseudonymizer by hand; use that type instead, since it also exposes the key identifier you need to interpret the result.

func (*Hasher) AnonymizeString

func (h *Hasher) AnonymizeString(addr netip.Addr) (string, error)

AnonymizeString implements Anonymizer, returning the encoded token.

Addresses exempted by the scope rules are returned in their normal textual form, and dropped addresses yield the empty string. A token is never a valid IP address, so the two cases are always distinguishable downstream.

func (*Hasher) Kind

func (h *Hasher) Kind() Kind

Kind returns KindHash.

func (*Hasher) Token

func (h *Hasher) Token(addr netip.Addr) (string, KeyID, error)

Token returns the encoded token along with the identifier of the key that produced it. Store the KeyID beside the token if you ever intend to rotate the key, so that you can tell which tokens are comparable.

func (*Hasher) TokenAt

func (h *Hasher) TokenAt(addr netip.Addr, t time.Time) (string, KeyID, error)

TokenAt is Hasher.Token for a specific point in time. It matters only when the KeyProvider rotates: use it to backfill historical records with the key that was in effect when each record was created, rather than with today's.

func (*Hasher) WithEncoding

func (h *Hasher) WithEncoding(e Encoding) *Hasher

WithEncoding returns a copy of h that renders tokens using e.

func (*Hasher) WithOutputBits

func (h *Hasher) WithOutputBits(n int) (*Hasher, error)

WithOutputBits returns a copy of h producing tokens of n bits. n must be a multiple of 8 between MinOutputBits and 256.

func (*Hasher) WithScopeRules

func (h *Hasher) WithScopeRules(r ScopeRules) *Hasher

WithScopeRules returns a copy of h that applies the given per-scope handling.

type KeyID

type KeyID string

KeyID is a short, non-secret label identifying which key produced a value. Store it alongside anonymized data so that you can tell which records share a key, and so that a key rotation is visible in the data rather than silently changing every token.

A KeyID is a fingerprint, not key material: it is derived from the key through a one-way function and reveals nothing about it.

type KeyProvider

type KeyProvider interface {
	// KeyAt returns the key active at t, along with its identifier. The
	// returned slice must not be modified by the caller and must remain valid
	// for the duration of the call that obtained it.
	KeyAt(t time.Time) ([]byte, KeyID, error)
}

KeyProvider supplies the key in effect at a given time.

Implementations must be safe for concurrent use.

type Kind

type Kind uint8

Kind identifies an anonymization technique.

const (
	KindInvalid Kind = iota
	KindDrop
	KindTruncate
	KindHash
	KindPseudonym
	KindCryptoPAn
)

The available techniques.

func ParseKind

func ParseKind(s string) (Kind, error)

ParseKind returns the Kind named by s, which must be one of the values produced by Kind.String.

func (Kind) Anonymous

func (k Kind) Anonymous() bool

Anonymous reports whether the technique yields data that is anonymous rather than merely pseudonymous, in the sense of GDPR Recital 26.

Only KindDrop qualifies. Every other technique in this package produces a value that is derived from, and to some degree linkable back to, the original address. See the package documentation.

func (Kind) String

func (k Kind) String() string

String returns the lowercase name of the technique, as accepted by ParseKind and the ipanon command.

type Policy

type Policy struct {
	// Technique selects the anonymizer. Required.
	Technique Kind

	// V4Bits and V6Bits are the prefix lengths kept by [KindTruncate].
	// Zero means the defaults, 24 and 48.
	V4Bits, V6Bits int

	// Key is the secret for [KindHash], [KindPseudonym], and [KindCryptoPAn].
	// It must be at least [KeyLen] bytes, and exactly [CryptoPAnKeyLen] for
	// Crypto-PAn. See [GenerateKey] and [KeyFromEnv].
	Key []byte

	// RotationPeriod is how often [KindPseudonym] derives a new key. Zero
	// means [RotateDaily].
	RotationPeriod time.Duration

	// OutputBits is the token width for [KindHash] and [KindPseudonym]. Zero
	// means [DefaultOutputBits].
	OutputBits int

	// Encoding renders tokens for [KindHash] and [KindPseudonym].
	Encoding Encoding

	// Scopes overrides handling per address scope. The zero value anonymizes
	// every scope. See [PassThroughInternal].
	Scopes ScopeRules

	// Reversible allows [KindCryptoPAn] to be built with the de-anonymization
	// capability exposed. It has no effect on other techniques, none of which
	// can be reversed at all.
	//
	// Setting this keeps the data squarely within the scope of the GDPR. Only
	// set it for a process that genuinely needs to resolve addresses, and keep
	// that process separate from the one writing logs.
	Reversible bool

	// Retention documents how long the anonymized output is kept. It is
	// advisory: this package does not delete anything. It is here because the
	// technique and the retention period are one decision, not two, and
	// recording them together is the only way the pairing survives review.
	Retention time.Duration
}

Policy is a declarative description of how a particular class of data should be anonymized, from which Policy.Build constructs the corresponding anonymizer.

It exists so that the choice of technique can live in configuration rather than at the call site. Two things follow from that. You can run a strict policy in production and a permissive one locally without conditionals in request handlers. And, more usefully, the policy value is a compact, reviewable statement of what you do with addresses, which is close to what a record of processing activities has to say anyway.

Fields not relevant to the chosen Kind are ignored.

func PolicyAnalytics

func PolicyAnalytics() Policy

PolicyAnalytics is the recommended default for web and product analytics: truncate to /24 and /48, matching what Google Analytics does, with a 26-month retention to match the common analytics ceiling.

Geolocate before this runs and store the country as its own field; see Truncator.

func PolicyGoogleAnalytics

func PolicyGoogleAnalytics() Policy

PolicyGoogleAnalytics is PolicyAnalytics without the retention hint, for when you only want the truncation behavior.

func PolicyRateLimit

func PolicyRateLimit(key []byte) Policy

PolicyRateLimit suits identifying a repeat source without building a profile: pseudonyms on an hourly key, which is long enough for abuse control and short enough that yesterday's traffic cannot be linked to today's.

The key must be supplied by the caller.

func PolicySecurityAudit

func PolicySecurityAudit(key []byte) Policy

PolicySecurityAudit suits authentication events, administrative actions, and anything else you may need to reconstruct during an incident.

It uses reversible Crypto-PAn, so the audit trail keeps its network structure and an authorized investigator holding the key can resolve an address when there is cause. That is a deliberate trade: this data remains personal data, and the justification for retaining it is the legitimate interest in security rather than anything to do with anonymization. Restrict access to both the logs and the key, and keep this policy well away from your analytics pipeline.

func PolicyStrict

func PolicyStrict() Policy

PolicyStrict drops addresses entirely. The only configuration here that produces genuinely anonymous data.

func (Policy) Build

func (p Policy) Build() (Anonymizer, error)

Build constructs the anonymizer the policy describes.

The concrete type depends on Policy.Technique; type-assert to AddrAnonymizer if you need address-shaped output, or to Reversible to find out whether the result can be de-anonymized.

func (Policy) Validate

func (p Policy) Validate() error

Validate reports whether the policy can produce a working anonymizer, without building one. Useful at startup, so a misconfigured deployment fails immediately rather than on its first request.

type Pseudonymizer

type Pseudonymizer struct {
	// contains filtered or unexported fields
}

Pseudonymizer replaces an address with a token that changes on a schedule.

Within one period the token for a given address is stable, so you can count distinct sources, spot a source hitting you repeatedly, and rate-limit per source. Across periods the token changes unpredictably, so the same visitor on Monday and Tuesday cannot be recognized as the same visitor. That is the point: it buys you the analytics that need short-term linkability without building a long-term profile.

Why this beats a lookup table

The obvious implementation of rotating pseudonyms is a map from address to random identifier, cleared on a timer. That map is itself a re-identification table: it holds every raw address you have seen, it has to be secured and disclosed like any other store of personal data, it does not survive a restart, and it does not agree between replicas.

A Pseudonymizer stores nothing. The period's key is derived from a master key with HKDF, and the token is an HMAC under that key, so every replica computes the same token independently, a restart changes nothing, and there is no table to leak or to produce in response to a subject access request.

What it is not

This is still pseudonymization. Anyone with the master key can recompute the token for a guessed address, and the IPv4 space is small enough to enumerate exhaustively. Rotation limits the exposure of a leaked key to the periods it covers; it does not eliminate it. Choose the period to match your retention: a key that rotates daily protects little if you keep 400 days of tokens and the master key is what leaks.

A Pseudonymizer is safe for concurrent use.

func NewPseudonymizer

func NewPseudonymizer(master []byte, period time.Duration) (*Pseudonymizer, error)

NewPseudonymizer returns a Pseudonymizer deriving a fresh key every period from master, which must be at least KeyLen bytes; see GenerateKey.

RotateDaily is the usual period. Shorter periods give stronger privacy and less analytical reach; a period shorter than your typical session means you cannot even correlate one visit end to end.

func NewPseudonymizerWithKeys

func NewPseudonymizerWithKeys(keys *RotatingKeys) (*Pseudonymizer, error)

NewPseudonymizerWithKeys returns a Pseudonymizer using an existing RotatingKeys, so that several anonymizers can share one derivation chain and one rotation schedule.

func (*Pseudonymizer) AnonymizeString

func (p *Pseudonymizer) AnonymizeString(addr netip.Addr) (string, error)

AnonymizeString implements Anonymizer, returning the token for the current period.

func (*Pseudonymizer) Kind

func (p *Pseudonymizer) Kind() Kind

Kind returns KindPseudonym.

func (*Pseudonymizer) Period

func (p *Pseudonymizer) Period() time.Duration

Period returns the rotation period.

func (*Pseudonymizer) Token

func (p *Pseudonymizer) Token(addr netip.Addr) (string, KeyID, error)

Token returns the token for the current period along with the identifier of the key that produced it.

func (*Pseudonymizer) TokenAt

func (p *Pseudonymizer) TokenAt(addr netip.Addr, t time.Time) (string, KeyID, error)

TokenAt returns the token for the period containing t.

Use it when anonymizing records after the fact: pass each record's own timestamp so that a log replayed on Friday still yields the tokens it would have had on Tuesday, and distinct-source counts per day stay correct.

func (*Pseudonymizer) WithEncoding

func (p *Pseudonymizer) WithEncoding(e Encoding) *Pseudonymizer

WithEncoding returns a copy of p that renders tokens using e.

func (*Pseudonymizer) WithKeyLabel

func (p *Pseudonymizer) WithKeyLabel(label bool) *Pseudonymizer

WithKeyLabel returns a copy of p that prefixes each token with the key identifier and a colon, for example "3f9a1c02.20291:a1b2...".

Turn this on when tokens from different periods end up in the same column. Without it, two tokens are simply unequal and you cannot tell whether that is because they are different sources or because a rotation happened in between, which quietly inflates distinct-source counts across a period boundary.

func (*Pseudonymizer) WithOutputBits

func (p *Pseudonymizer) WithOutputBits(n int) (*Pseudonymizer, error)

WithOutputBits returns a copy of p producing tokens of n bits. n must be a multiple of 8 between MinOutputBits and 256.

func (*Pseudonymizer) WithScopeRules

func (p *Pseudonymizer) WithScopeRules(r ScopeRules) *Pseudonymizer

WithScopeRules returns a copy of p that applies the given per-scope handling.

func (*Pseudonymizer) Zero

func (p *Pseudonymizer) Zero()

Zero overwrites the master key and any derived period keys. The Pseudonymizer must not be used afterwards. Best-effort; see StaticKey.Zero.

type Reversible

type Reversible interface {
	Deanonymize(addr netip.Addr) (netip.Addr, error)
}

Reversible is implemented by techniques that are pseudonymization rather than anonymization: a key holder can recover the original address.

This interface exists so that reversibility is visible in the type system. Data produced by a Reversible technique remains personal data.

type ReversibleCryptoPAn

type ReversibleCryptoPAn struct {
	CryptoPAn
}

ReversibleCryptoPAn is a CryptoPAn that also exposes the inverse mapping.

Keep it out of the code path that writes logs. The usual arrangement is that the ingest path holds a plain CryptoPAn and cannot reverse anything, while a separate, access-controlled tool holds the key and constructs this type when an incident genuinely requires resolving an anonymized address.

func NewReversibleCryptoPAn

func NewReversibleCryptoPAn(key []byte) (*ReversibleCryptoPAn, error)

NewReversibleCryptoPAn returns a ReversibleCryptoPAn for key.

Constructing this type is a deliberate decision to retain re-identification capability. Under the GDPR that capability is precisely what keeps the data in scope, so record why you need it.

func (*ReversibleCryptoPAn) Deanonymize

func (c *ReversibleCryptoPAn) Deanonymize(addr netip.Addr) (netip.Addr, error)

Deanonymize implements Reversible, recovering the original address.

It has no way to detect an address that was never anonymized, or that was anonymized under a different key: it will return a plausible-looking wrong answer instead of an error. Track which key produced a dataset.

func (*ReversibleCryptoPAn) WithScopeRules

func (c *ReversibleCryptoPAn) WithScopeRules(r ScopeRules) *ReversibleCryptoPAn

WithScopeRules returns a copy of c that applies the given per-scope handling.

Note that a scope configured to pass through or drop is not recoverable by ReversibleCryptoPAn.Deanonymize, since no anonymization took place.

type RotatingKeys

type RotatingKeys struct {
	// contains filtered or unexported fields
}

RotatingKeys is a KeyProvider that derives a fresh key for each period from a long-lived master key using HKDF-SHA256.

Rotation bounds the damage from a key compromise in time. An attacker who obtains the epoch key for one day can de-pseudonymize that day's data and nothing else; an attacker who obtains the master key can derive every epoch, so the master key is the thing to protect.

Because epoch keys are derived rather than stored, rotation needs no coordination: every process holding the same master key computes the same epoch key independently, and there is no mapping table to secure or to produce in response to a subject access request.

Epochs are aligned to the Unix epoch, not to the time the provider was created, so all processes roll over together.

func NewRotatingKeys

func NewRotatingKeys(master []byte, period time.Duration) (*RotatingKeys, error)

NewRotatingKeys returns a RotatingKeys deriving from master with the given rotation period. The master key must be at least KeyLen bytes and is copied. Period must be positive; see RotateDaily and friends.

func (*RotatingKeys) Epoch

func (r *RotatingKeys) Epoch(t time.Time) int64

Epoch returns the epoch number covering t. Values anonymized in the same epoch share a key and are mutually linkable; values in different epochs are not.

func (*RotatingKeys) KeyAt

func (r *RotatingKeys) KeyAt(t time.Time) ([]byte, KeyID, error)

KeyAt implements KeyProvider, returning the key for the epoch containing t.

func (*RotatingKeys) Period

func (r *RotatingKeys) Period() time.Duration

Period returns the rotation period.

func (*RotatingKeys) Zero

func (r *RotatingKeys) Zero()

Zero overwrites the master key and every cached epoch key. The RotatingKeys must not be used afterwards. Best-effort; see StaticKey.Zero.

type Scope

type Scope uint8

Scope classifies an address by the network it belongs to. Callers use it to apply different handling to addresses that are not personal data in the first place, such as loopback traffic from a health checker.

const (
	// ScopeGlobal is a publicly routable address. This is the only scope that
	// reliably identifies an external party.
	ScopeGlobal Scope = iota
	// ScopeUnspecified is 0.0.0.0 or ::.
	ScopeUnspecified
	// ScopeLoopback is 127.0.0.0/8 or ::1.
	ScopeLoopback
	// ScopeLinkLocal is 169.254.0.0/16, fe80::/10, or a multicast link-local
	// address.
	ScopeLinkLocal
	// ScopePrivate is RFC 1918 space (10/8, 172.16/12, 192.168/16) or an IPv6
	// unique local address (fc00::/7).
	ScopePrivate
	// ScopeCGNAT is RFC 6598 carrier-grade NAT space, 100.64.0.0/10. Addresses
	// here are shared by many subscribers of one ISP.
	ScopeCGNAT
	// ScopeMulticast is any non-link-local multicast address.
	ScopeMulticast
)

Address scopes, in the order ClassifyScope tests them.

func ClassifyScope

func ClassifyScope(addr netip.Addr) Scope

ClassifyScope returns the Scope of addr. The address should already be normalized; ClassifyScope normalizes defensively so that a mapped IPv4 address classifies as IPv4 space.

func (Scope) String

func (s Scope) String() string

String returns the lowercase name of the scope.

type ScopeRules

type ScopeRules struct {
	Unspecified Action
	Loopback    Action
	LinkLocal   Action
	Private     Action
	CGNAT       Action
	Multicast   Action
}

ScopeRules overrides the handling of addresses by scope. The zero value anonymizes every scope, which is always safe. Globally routable addresses are always anonymized and cannot be overridden.

A common configuration passes internal traffic through unchanged, since a loopback or RFC 1918 address identifies a machine in your own estate rather than a data subject:

rules := ipanon.ScopeRules{
	Loopback: ipanon.ActionPassThrough,
	Private:  ipanon.ActionPassThrough,
}

Note that ScopeCGNAT is deliberately not grouped with Private. A carrier- grade NAT address belongs to an ISP subscriber and is personal data.

func PassThroughInternal

func PassThroughInternal() ScopeRules

PassThroughInternal returns rules that leave loopback, link-local, unspecified, and RFC 1918 addresses untouched while anonymizing everything else, including carrier-grade NAT space.

type StaticKey

type StaticKey struct {
	// contains filtered or unexported fields
}

StaticKey is a KeyProvider that always returns the same key.

Use it when you want tokens to stay linkable indefinitely, for example to count lifetime distinct users. Be aware of the trade-off: with a static key there is no point at which old data stops being re-identifiable by whoever holds the key. Prefer RotatingKeys unless you specifically need indefinite linkage.

func NewStaticKey

func NewStaticKey(key []byte) (*StaticKey, error)

NewStaticKey returns a StaticKey wrapping key, which must be at least KeyLen bytes. The key is copied.

func (*StaticKey) KeyAt

func (s *StaticKey) KeyAt(time.Time) ([]byte, KeyID, error)

KeyAt implements KeyProvider. The time is ignored.

func (*StaticKey) Zero

func (s *StaticKey) Zero()

Zero overwrites the stored key material. The StaticKey must not be used afterwards. This is best-effort: the Go runtime may have copied the key during garbage collection, and this does not reach any such copy.

type Truncator

type Truncator struct {
	// contains filtered or unexported fields
}

Truncator zeroes the host bits of an address, keeping a fixed-length network prefix.

This is the most widely deployed technique and the one Google Analytics applies for its IP anonymization feature. It is cheap, needs no key, and preserves enough of the address that country- and often region-level geolocation still works, along with the owning ASN.

What it does not do is make the data anonymous. A /24 has 256 addresses, and many of them are unused; combined with a timestamp, a user agent, and a URL path, a truncated address frequently still singles out one household. Treat the output as pseudonymous personal data.

If you need geolocation, geolocate the full address first and store the resulting country or region as its own field. Truncating first and geolocating afterwards loses precision for no privacy gain, because you already handled the full address.

A Truncator is safe for concurrent use, and Truncator.AnonymizeAddr does not allocate.

func NewTruncator

func NewTruncator(v4Bits, v6Bits int) (*Truncator, error)

NewTruncator returns a Truncator keeping the leading v4Bits of an IPv4 address and the leading v6Bits of an IPv6 address. Both must be in range for their family: 0 to 32, and 0 to 128.

Fewer bits means more privacy and less analytical value. See TruncateGA and TruncateStrict for the two configurations worth defaulting to.

func TruncateGA

func TruncateGA() *Truncator

TruncateGA returns the truncation Google Analytics performs when IP anonymization is enabled: /24 for IPv4 and /48 for IPv6.

This is the de facto industry baseline and what most data protection authorities have seen before. It is a reasonable default for web analytics.

func TruncateStrict

func TruncateStrict() *Truncator

TruncateStrict returns an aggressive truncation, /16 for IPv4 and /32 for IPv6, which collapses each address into a large block.

Use it when you want little more than a rough sense of network origin. Be aware that a /16 can span multiple cities and occasionally multiple countries, so geolocation of the truncated value is unreliable; geolocate before truncating.

func (*Truncator) AnonymizeAddr

func (t *Truncator) AnonymizeAddr(addr netip.Addr) (netip.Addr, error)

AnonymizeAddr implements AddrAnonymizer, returning the base address of the network addr falls into.

func (*Truncator) AnonymizeString

func (t *Truncator) AnonymizeString(addr netip.Addr) (string, error)

AnonymizeString implements Anonymizer. It returns the truncated address in its usual textual form, for example "192.168.42.0" or "2001:db8:1234::".

func (*Truncator) Bits

func (t *Truncator) Bits() (v4, v6 int)

Bits returns the IPv4 and IPv6 prefix lengths this Truncator keeps.

func (*Truncator) Kind

func (t *Truncator) Kind() Kind

Kind returns KindTruncate.

func (*Truncator) Prefix

func (t *Truncator) Prefix(addr netip.Addr) (netip.Prefix, error)

Prefix returns the network that addr is truncated to, which is useful when you want to store or aggregate by the prefix itself rather than by its base address.

func (*Truncator) WithScopeRules

func (t *Truncator) WithScopeRules(r ScopeRules) *Truncator

WithScopeRules returns a copy of t that applies the given per-scope handling. See ScopeRules and PassThroughInternal.

Directories

Path Synopsis
cmd
ipanon command
Command ipanon anonymizes IP addresses in log files and streams.
Command ipanon anonymizes IP addresses in log files and streams.
Package httpanon anonymizes client IP addresses in net/http servers.
Package httpanon anonymizes client IP addresses in net/http servers.
Package kanon generalizes IP addresses until each released value covers at least k observed sources.
Package kanon generalizes IP addresses until each released value covers at least k observed sources.
Package redact rewrites IP addresses found in arbitrary text.
Package redact rewrites IP addresses found in arbitrary text.
Package sloganon anonymizes IP addresses in structured log records.
Package sloganon anonymizes IP addresses in structured log records.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL