- 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
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
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
|
||
}
|