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 ¶
- Variables
- func Fingerprint(compressed []byte) string
- func Save(path, header string, recs []Record) error
- type Kind
- type Record
- func ActiveFor(recs []Record, address string) (Record, bool)
- func Forget(recs []Record, address string, all bool) []Record
- func Load(path string) ([]Record, error)
- func Parse(r io.Reader) ([]Record, error)
- func Pin(recs []Record, address, keyHex string, seq uint64, fp string, now time.Time) ([]Record, error)
- func Retire(recs []Record, address string, now time.Time) []Record
- func Rotate(recs []Record, address, newKeyHex string, seq uint64, fp string, now time.Time) ([]Record, error)
Examples ¶
Constants ¶
This section is empty.
Variables ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.