mirror of
https://github.com/luxfi/zap.git
synced 2026-10-30 15:04:16 +00:00
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package handshake
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"hash"
|
|
|
|
"golang.org/x/crypto/sha3"
|
|
)
|
|
|
|
// Transcript chains SHA3-256 over every handshake byte (§7).
|
|
//
|
|
// The state machine:
|
|
//
|
|
// NewTranscript(suite)
|
|
// AbsorbHello(helloBody) -> commits H_0
|
|
// AbsorbKEM(initBody, replyBody) -> commits H_1
|
|
// FinishFull(pkI, pkR, schemes) -> returns H_2 (full handshake)
|
|
// -- OR --
|
|
// FinishPSK(serverEphX25519Pub) -> returns H_2_psk (resumed handshake)
|
|
//
|
|
// Each step replaces the internal SHA3-256 state with the digest of
|
|
// the previous chain ∥ new material. This matches the spec's
|
|
// definition of H_n as `SHA3-256(H_{n-1} ∥ <new bytes>)`.
|
|
//
|
|
// The encoded body of each frame is whatever is between the outer
|
|
// type/length fields — i.e. the slice the codec returns / consumes.
|
|
// Callers MUST feed exactly those bytes (not the outer envelope) so
|
|
// both sides agree on the transcript without re-running the codec.
|
|
type Transcript struct {
|
|
state [TranscriptLen]byte
|
|
suite SuiteID
|
|
stage transcriptStage
|
|
h hash.Hash // reused SHA3-256 instance
|
|
}
|
|
|
|
type transcriptStage uint8
|
|
|
|
const (
|
|
stageNew transcriptStage = iota // before AbsorbHello
|
|
stageH0 // after AbsorbHello
|
|
stageH1 // after AbsorbKEM
|
|
stageH2 // after FinishFull / FinishPSK
|
|
)
|
|
|
|
// NewTranscript creates a transcript pinned to the supplied suite. The
|
|
// suite byte does not enter the state at construction — it enters via
|
|
// the H_0 prefix in AbsorbHello so the resulting H_0 already binds it.
|
|
func NewTranscript(suite SuiteID) *Transcript {
|
|
return &Transcript{suite: suite, h: sha3.New256()}
|
|
}
|
|
|
|
// AbsorbHello commits H_0 = SHA3-256(LBL_PROTOCOL ∥ 0x00 ∥ suite ∥ hello).
|
|
// The hello argument is the wire-encoded HELLO body (everything after
|
|
// the outer type ∥ length envelope), per §7.
|
|
func (t *Transcript) AbsorbHello(hello []byte) {
|
|
t.h.Reset()
|
|
_, _ = t.h.Write(LblProtocol)
|
|
_, _ = t.h.Write([]byte{0x00})
|
|
_, _ = t.h.Write([]byte{byte(t.suite)})
|
|
_, _ = t.h.Write(hello)
|
|
t.h.Sum(t.state[:0])
|
|
t.stage = stageH0
|
|
}
|
|
|
|
// AbsorbKEM commits H_1 = SHA3-256(H_0 ∥ init ∥ reply).
|
|
//
|
|
// init = wire-encoded KEM_INIT body, reply = wire-encoded KEM_REPLY
|
|
// body. The two are folded in one digest call because the spec
|
|
// chains them inside a single SHA3 instance, not as two separate H_n
|
|
// steps.
|
|
func (t *Transcript) AbsorbKEM(init, reply []byte) {
|
|
t.h.Reset()
|
|
_, _ = t.h.Write(t.state[:])
|
|
_, _ = t.h.Write(init)
|
|
_, _ = t.h.Write(reply)
|
|
t.h.Sum(t.state[:0])
|
|
t.stage = stageH1
|
|
}
|
|
|
|
// FinishFull commits H_2 for the full handshake:
|
|
//
|
|
// H_2 = SHA3-256(H_1 ∥ static_pk_I ∥ static_pk_R ∥ offered_schemes_encoded)
|
|
//
|
|
// offered_schemes_encoded is `u32(len) ∥ bytes(schemes)` — the same
|
|
// byte sequence already inside HELLO. It is re-mixed here so that any
|
|
// on-the-wire tamper with the scheme list under the magic-prefix layer
|
|
// (e.g. a downgrader stripping `0x01`) would compute a different H_2
|
|
// than what the signer signed — AUTH then fails, ALERT 0x03 fires.
|
|
func (t *Transcript) FinishFull(pkI, pkR []byte, schemes []SuiteID) [TranscriptLen]byte {
|
|
t.h.Reset()
|
|
_, _ = t.h.Write(t.state[:])
|
|
_, _ = t.h.Write(pkI)
|
|
_, _ = t.h.Write(pkR)
|
|
// re-encode schemes identically to §6.1 / §7
|
|
var lp [4]byte
|
|
binary.BigEndian.PutUint32(lp[:], uint32(len(schemes)))
|
|
_, _ = t.h.Write(lp[:])
|
|
for _, s := range schemes {
|
|
_, _ = t.h.Write([]byte{byte(s)})
|
|
}
|
|
t.h.Sum(t.state[:0])
|
|
t.stage = stageH2
|
|
return t.state
|
|
}
|
|
|
|
// FinishPSK is the §7 resumption transcript:
|
|
//
|
|
// H_2_psk = SHA3-256(H_0_psk ∥ x25519_pk_eph_responder)
|
|
//
|
|
// Where H_0_psk was committed by AbsorbHello over the HELLO_PSK body.
|
|
// The resumption path does NOT absorb KEM frames — possession of the
|
|
// resumption_psk is the authentication and ML-KEM is skipped.
|
|
func (t *Transcript) FinishPSK(serverEphX25519Pub []byte) [TranscriptLen]byte {
|
|
t.h.Reset()
|
|
_, _ = t.h.Write(t.state[:])
|
|
_, _ = t.h.Write(serverEphX25519Pub)
|
|
t.h.Sum(t.state[:0])
|
|
t.stage = stageH2
|
|
return t.state
|
|
}
|
|
|
|
// H0, H1, H2 return the most recently committed digest at each stage.
|
|
// They are introspection helpers for tests / KAT vectors; production
|
|
// code chains FinishFull or FinishPSK directly into the KDF.
|
|
func (t *Transcript) H0() [TranscriptLen]byte {
|
|
if t.stage < stageH0 {
|
|
return [TranscriptLen]byte{}
|
|
}
|
|
if t.stage == stageH0 {
|
|
return t.state
|
|
}
|
|
// past H_0 — we no longer hold it. Tests should snapshot in stage.
|
|
return [TranscriptLen]byte{}
|
|
}
|
|
|
|
func (t *Transcript) H1() [TranscriptLen]byte {
|
|
if t.stage == stageH1 {
|
|
return t.state
|
|
}
|
|
return [TranscriptLen]byte{}
|
|
}
|
|
|
|
func (t *Transcript) H2() [TranscriptLen]byte {
|
|
if t.stage == stageH2 {
|
|
return t.state
|
|
}
|
|
return [TranscriptLen]byte{}
|
|
}
|