niko_trust/pkg/protocol/rotation.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
  external Go projects can import the verified core; invariant tests
  updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
  sits behind a reverse proxy (off by default, header never trusted
  otherwise)
- examples/service + examples/approve: complete passwordless login round
  trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
2026-08-26 12:49:54 +03:00

246 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package protocol
// Key rotation: how an identity changes its key without losing its history.
//
// Two linked objects, mirroring the approval pattern:
//
// KeyRotationRequest (tag 0x08) — signed by the SUCCESSOR.
// "I, this key, succeed predecessor."
// KeyRotationConfirm (tag 0x09) — signed by the PREDECESSOR.
// "I consent to that exact request."
//
// Neither object alone proves anything: a request names any predecessor it
// likes, and a consent is unintelligible without the request it hashes. The
// binding is by content ID (INV-4), so a signed decision can never be moved
// to a different request. Together they are evidence both keys agreed.
//
// Freshness over long horizons is the verifier's business
// (Policy.RotationMaxAge), not the wire's: the request lives at most sixty
// seconds, exactly like an approval request.
import (
"bytes"
"crypto/subtle"
"git.n1ko.dev/Niko/niko_trust/pkg/address"
"git.n1ko.dev/Niko/niko_trust/pkg/tce"
)
// KeyRotationRequest is the incoming key's claim of succession, tag 0x08.
type KeyRotationRequest struct {
// Successor signs this object and becomes the identity's new key once
// the rotation is accepted.
Successor []byte
// Predecessor is the key being succeeded and the only identity whose
// confirmation counts.
Predecessor []byte
CreatedAt uint64
// ExpiresAt must be after CreatedAt by at most 60 seconds.
ExpiresAt uint64
tce []byte
sig []byte
}
func (o *KeyRotationRequest) TCE() []byte { return bytes.Clone(o.tce) }
func (o *KeyRotationRequest) Signature() []byte { return bytes.Clone(o.sig) }
// KeyRotationConfirm is the predecessor's signed consent, tag 0x09.
type KeyRotationConfirm struct {
// RotationHash is the object ID of the exact canonical request bytes.
RotationHash tce.ID
CreatedAt uint64
Nonce []byte
tce []byte
sig []byte
}
func (o *KeyRotationConfirm) TCE() []byte { return bytes.Clone(o.tce) }
func (o *KeyRotationConfirm) Signature() []byte { return bytes.Clone(o.sig) }
// -------------------------------------------------------------------- codec
// EncodeKeyRotationRequest encodes tag 0x08: successor, predecessor,
// created_at, expires_at.
func EncodeKeyRotationRequest(o *KeyRotationRequest) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Successor); err != nil {
return nil, fieldErr("successor", err)
}
if err := address.ValidatePubKey(o.Predecessor); err != nil {
return nil, fieldErr("predecessor", err)
}
if o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if o.ExpiresAt-o.CreatedAt > tce.MaxApprovalLifetime {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
e := tce.NewEncoder()
e.Header(tce.TagKeyRotation)
e.Identity("successor", o.Successor)
e.Identity("predecessor", o.Predecessor)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, false)
return finishEncode(e, tce.MaxKeyRotTCE)
}
// DecodeKeyRotationRequest parses canonical bytes of tag 0x08.
func DecodeKeyRotationRequest(b []byte) (*KeyRotationRequest, error) {
if len(b) > tce.MaxKeyRotTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagKeyRotation {
return nil, ErrWrongObject
}
successor, err := decodeIdentityField(d, "successor")
if err != nil {
return nil, err
}
predecessor, err := decodeIdentityField(d, "predecessor")
if err != nil {
return nil, err
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
expiresAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("expires_at", err)
}
if expiresAt <= createdAt || expiresAt-createdAt > tce.MaxApprovalLifetime {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &KeyRotationRequest{
Successor: successor,
Predecessor: predecessor,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
o.tce = bytes.Clone(b)
return o, nil
}
// EncodeKeyRotationConfirm encodes tag 0x09: rotation_hash, created_at,
// nonce.
func EncodeKeyRotationConfirm(o *KeyRotationConfirm) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagKeyRotationConf)
e.FixedBytes("rotation_hash", o.RotationHash[:], tce.HashSize)
e.Timestamp("created_at", o.CreatedAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxKeyRotTCE)
}
// DecodeKeyRotationConfirm parses canonical bytes of tag 0x09.
func DecodeKeyRotationConfirm(b []byte) (*KeyRotationConfirm, error) {
if len(b) > tce.MaxKeyRotTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagKeyRotationConf {
return nil, ErrWrongObject
}
hash, err := d.FixedBytes(tce.HashSize)
if err != nil {
return nil, fieldErr("rotation_hash", err)
}
var o KeyRotationConfirm
copy(o.RotationHash[:], hash)
if o.CreatedAt, err = d.Timestamp(false); err != nil {
return nil, fieldErr("created_at", err)
}
nonce, err := d.FixedBytes(tce.NonceSize)
if err != nil {
return nil, fieldErr("nonce", err)
}
o.Nonce = nonce
if err := checkEnd(d); err != nil {
return nil, err
}
o.tce = bytes.Clone(b)
return &o, nil
}
// --------------------------------------------------------------- verifying
// VerifyKeyRotationRequest decodes and checks the signature under the
// successor's key.
func VerifyKeyRotationRequest(tceBytes, sig []byte) (*KeyRotationRequest, error) {
o, err := DecodeKeyRotationRequest(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.Successor, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyKeyRotationConfirm decodes and checks the signature under the
// successor-named predecessor. The confirm is only meaningful bound to its
// request; there is deliberately no standalone acceptance.
func VerifyKeyRotationConfirm(reqTCE, reqSig, confTCE, confSig []byte) (*KeyRotationConfirm, error) {
req, err := VerifyKeyRotationRequest(reqTCE, reqSig)
if err != nil {
return nil, err
}
conf, err := DecodeKeyRotationConfirm(confTCE)
if err != nil {
return nil, err
}
if err := verifySignature(req.Predecessor, conf.tce, confSig); err != nil {
return nil, err
}
// The consent commits to the full canonical request (INV-4).
want := tce.ComputeID(req.tce)
if subtle.ConstantTimeCompare(conf.RotationHash[:], want[:]) != 1 {
return nil, ErrRequestMismatch
}
conf.sig = make([]byte, len(confSig))
copy(conf.sig, confSig)
return conf, nil
}
// RotationWindowOK applies the approval-style tolerance interpretation: the
// confirm must sit within [req.created skew, req.expires + skew].
func RotationWindowOK(req *KeyRotationRequest, conf *KeyRotationConfirm) bool {
if req.CreatedAt > conf.CreatedAt && req.CreatedAt-conf.CreatedAt > MaxClockSkew {
return false
}
if req.ExpiresAt < conf.CreatedAt && conf.CreatedAt-req.ExpiresAt > MaxClockSkew {
return false
}
return true
}