knownkeys

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Oct 7, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package knownkeys reads and writes a pin store: the key each address was last seen with, one line per record.

The pin is what a signature check cannot give. Every other check asks "is this signed by the key it names"; the pin asks "is that the key I saw last time". Without it a host that can swap the key it answers with can impersonate anyone it serves, and every signature check faithfully verifies the impostor.

The grammar is defined HERE and nowhere else. A pin file is meant to have more than one reader, and two readers of an unspecified format is how a pin silently fails open, so a second reader should vendor a sample's bytes and parse them with its own code. testdata/known_keys.sample is this package's sample, one record of each form. Where the file lives and the header line that opens it belong to the application: Save takes the header, and this package has no default path.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrPermissions = errors.New("known_keys: refusing a file that is group or world writable")

ErrPermissions reports a store other users could write to. It is refused, ssh-style, because a pin somebody else can edit is not a pin.

Functions

func Fingerprint

func Fingerprint(compressed []byte) string

Fingerprint is SHA256: plus unpadded base64 over the 33-byte compressed key.

Unpadded on purpose: it is read aloud and compared by eye at first contact, and trailing '=' is the character people drop when they retype one.

func Save

func Save(path, header string, recs []Record) error

Save writes the store atomically: the directory is created 0700 if missing, the bytes go to a temporary file in the same directory at 0600, fsync puts them on the disk, and a rename makes them visible. A reader never sees a half-written pin.

The file is header, then one Line per record, and nothing else. The header is the application's first line, a comment naming the file and its version such as "# example known_keys v1", passed without its line break. It is required: an empty header is refused rather than written as a file with no header line, because a caller that left it out would otherwise write stores that no longer say what they are. A header that is not a comment, or that holds a line break, is refused as well, because Parse would read it, or what follows the break, as a record line: one it refuses fails every later Load, and one it accepts is a pin nobody pinned. Each refusal comes before anything touches the disk.

The fsync is not belt and braces here. A rename is atomic in VISIBILITY, not in durability, so a crash can leave the rename on disk and the bytes not, and this is the one file whose loss fails OPEN: an empty or truncated store reads as first contact for every address it used to pin, and the application that would have refused an impostor greets it instead.

Types

type Kind

type Kind int

Kind distinguishes the three record forms.

const (
	// Active is the pin in force. At most one per address.
	Active Kind = iota
	// RotatedFrom is history. It is NEVER matched for verification: a
	// superseded key that could still satisfy a pin is not a rotation, it is
	// a second valid key.
	RotatedFrom
	// Retired is terminal. Any later answer for the address is refused.
	Retired
)

type Record

type Record struct {
	Kind     Kind
	Address  string
	Algo     string
	KeyHex   string
	Seq      uint64
	UntilSeq uint64
	First    time.Time
	Last     time.Time
	At       time.Time
	// Fingerprint is the line's fp= value as written, for a person to
	// compare by eye. Parse does not check it against KeyHex.
	Fingerprint string
	Raw         string
}

Record is one line.

func ActiveFor

func ActiveFor(recs []Record, address string) (Record, bool)

ActiveFor returns the pin in force for an address.

A retired address returns its retirement record and ok=false: retired is not "no pin", it is a pin that refuses, and collapsing the two would let a retired identity be re-trusted on first contact.

func Forget

func Forget(recs []Record, address string, all bool) []Record

Forget removes the pin in force for address, active or retired; with all, its @rotated-from history too. History is kept by default because it is what explains a later first contact to whoever reads the file.

func Load

func Load(path string) ([]Record, error)

Load reads the store at path. A missing file is an empty store, not an error: first contact for everything is the correct state of a fresh install.

func Parse

func Parse(r io.Reader) ([]Record, error)

Parse reads a known_keys file.

A malformed line is an ERROR, not a skip. Skipping one would drop a pin and leave the application trusting first contact again for that address, which is the failure this file exists to prevent, arriving silently.

A second active record for an address is malformed in the same sense: the file would hold two answers to the one question it exists to answer, and ActiveFor would return whichever line came first.

func Pin

func Pin(recs []Record, address, keyHex string, seq uint64, fp string, now time.Time) ([]Record, error)

Pin records a first contact or an advance of an existing pin: the active record for address becomes keyHex at seq, with first/last stamped. Any previous active record for the address with the SAME key is replaced in place; one with a DIFFERENT key is an error, because replacing it silently is precisely what this file exists to prevent (use Rotate or Forget). A key that is not the one canonical compressed encoding of its point, in lower-case hex, is refused.

Example

The key an address was first seen with is pinned; a different key for the same address is refused unless the application verified a rotation.

package main

import (
	"encoding/hex"
	"fmt"
	"time"

	"github.com/lightwebinc/bcommon/knownkeys"
)

func main() {
	first := "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
	next := "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"
	fp := func(keyHex string) string {
		b, _ := hex.DecodeString(keyHex)
		return knownkeys.Fingerprint(b)
	}
	now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)

	recs, err := knownkeys.Pin(nil, "alice@example.com", first, 1, fp(first), now)
	if err != nil {
		fmt.Println(err)
		return
	}
	_, err = knownkeys.Pin(recs, "alice@example.com", next, 2, fp(next), now)
	fmt.Println("another key:", err != nil)

	recs, err = knownkeys.Rotate(recs, "alice@example.com", next, 2, fp(next), now.Add(time.Hour))
	if err != nil {
		fmt.Println(err)
		return
	}
	for _, r := range recs {
		fmt.Println(r.Line())
	}
}
Output:
another key: true
@rotated-from alice@example.com secp256k1 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 until_seq=1 at=2026-01-02T04:04:05Z
alice@example.com secp256k1 0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c seq=2 first=2026-01-02T04:04:05Z last=2026-01-02T04:04:05Z fp=SHA256:p80oF5TbGEhPJA2b38CSIauJ63wV48p6tnwwY89tfjc

func Retire

func Retire(recs []Record, address string, now time.Time) []Record

Retire marks address retired: the active record becomes @retired, which refuses every later answer for it.

func Rotate

func Rotate(recs []Record, address, newKeyHex string, seq uint64, fp string, now time.Time) ([]Record, error)

Rotate replaces the active key for address with newKeyHex after a verified rotation: the old record becomes @rotated-from history with until_seq set to the last sequence it covered, and a fresh active record is appended. The new key is held to Pin's rule.

func (Record) Line

func (r Record) Line() string

Line renders a record in the grammar Parse reads. Only fields that are set are written, and only fields Parse understands, so what this writes, Parse and any second reader of the grammar accept.

Jump to

Keyboard shortcuts

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