268 lines
6.8 KiB
Go
268 lines
6.8 KiB
Go
package main
|
|
|
|
// Decoding of the retired MMUR1 user-data record (meta-watch record/canonical.rs
|
|
// at meta-core v1.2.0) and the private-tier envelope (record/crypto.rs).
|
|
//
|
|
// record = canonical CBOR array(7) [cid, uid, key, value, version, ts, sig]
|
|
// bytes_to_sign = "MMUR1\n" || array(6) [cid, uid, key, value, version, ts]
|
|
// sig = DER ECDSA-secp256k1 over SHA-256(bytes_to_sign)
|
|
//
|
|
// value is CBOR null / bool / int / float / text / bytes (bytes = a private-tier
|
|
// XChaCha20-Poly1305 envelope whose plaintext is the CBOR of the typed value).
|
|
// The signed bytes are rebuilt from the record's own raw element bytes (array
|
|
// header 0x87 → 0x86, sig element cut off), so the old signature is checked
|
|
// against exactly what was signed, whatever float width the encoder chose.
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
)
|
|
|
|
const mmur1Domain = "MMUR1\n"
|
|
|
|
type cborKind uint8
|
|
|
|
const (
|
|
cNull cborKind = iota
|
|
cBool
|
|
cInt
|
|
cFloat
|
|
cText
|
|
cBytes
|
|
)
|
|
|
|
// cborItem is one decoded scalar plus its raw encoding.
|
|
type cborItem struct {
|
|
kind cborKind
|
|
b bool
|
|
i int64
|
|
f float64
|
|
s string
|
|
bytes []byte
|
|
raw []byte
|
|
}
|
|
|
|
// wireText is the MMUR2 text form of a typed value — meta-watch's
|
|
// Value::to_wire_text: bools "true"/"false", ints in decimal, floats as Rust's
|
|
// f64 Display (shortest round-trip, never an exponent), text verbatim.
|
|
func (it cborItem) wireText() (string, bool) {
|
|
switch it.kind {
|
|
case cBool:
|
|
if it.b {
|
|
return "true", true
|
|
}
|
|
return "false", true
|
|
case cInt:
|
|
return strconv.FormatInt(it.i, 10), true
|
|
case cFloat:
|
|
return strconv.FormatFloat(it.f, 'f', -1, 64), true
|
|
case cText:
|
|
return it.s, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
type mmur1Record struct {
|
|
CID, UID, Key string
|
|
Value cborItem
|
|
Version uint64
|
|
TS int64
|
|
Sig []byte
|
|
Signed []byte // the exact MMUR1 bytes the signature covers
|
|
}
|
|
|
|
var errCBOR = errors.New("malformed MMUR1 CBOR")
|
|
|
|
// readHead parses a CBOR initial byte + argument (definite lengths only).
|
|
func readHead(b []byte, off int) (major byte, ai byte, n uint64, next int, err error) {
|
|
if off >= len(b) {
|
|
return 0, 0, 0, 0, errCBOR
|
|
}
|
|
ib := b[off]
|
|
major, ai = ib>>5, ib&31
|
|
off++
|
|
var size int
|
|
switch {
|
|
case ai < 24:
|
|
return major, ai, uint64(ai), off, nil
|
|
case ai == 24:
|
|
size = 1
|
|
case ai == 25:
|
|
size = 2
|
|
case ai == 26:
|
|
size = 4
|
|
case ai == 27:
|
|
size = 8
|
|
default:
|
|
return 0, 0, 0, 0, fmt.Errorf("%w: indefinite or reserved length", errCBOR)
|
|
}
|
|
if off+size > len(b) {
|
|
return 0, 0, 0, 0, errCBOR
|
|
}
|
|
for _, x := range b[off : off+size] {
|
|
n = n<<8 | uint64(x)
|
|
}
|
|
return major, ai, n, off + size, nil
|
|
}
|
|
|
|
// readItem decodes one scalar item at off.
|
|
func readItem(b []byte, off int) (cborItem, int, error) {
|
|
start := off
|
|
major, ai, n, off, err := readHead(b, off)
|
|
if err != nil {
|
|
return cborItem{}, 0, err
|
|
}
|
|
var it cborItem
|
|
switch major {
|
|
case 0:
|
|
if n > math.MaxInt64 {
|
|
return it, 0, fmt.Errorf("%w: int out of range", errCBOR)
|
|
}
|
|
it = cborItem{kind: cInt, i: int64(n)}
|
|
case 1:
|
|
if n > math.MaxInt64 {
|
|
return it, 0, fmt.Errorf("%w: int out of range", errCBOR)
|
|
}
|
|
it = cborItem{kind: cInt, i: -1 - int64(n)}
|
|
case 2, 3:
|
|
if n > uint64(len(b)-off) {
|
|
return it, 0, errCBOR
|
|
}
|
|
end := off + int(n)
|
|
if major == 2 {
|
|
it = cborItem{kind: cBytes, bytes: append([]byte(nil), b[off:end]...)}
|
|
} else {
|
|
it = cborItem{kind: cText, s: string(b[off:end])}
|
|
}
|
|
off = end
|
|
case 7:
|
|
switch ai {
|
|
case 20, 21:
|
|
it = cborItem{kind: cBool, b: ai == 21}
|
|
case 22:
|
|
it = cborItem{kind: cNull}
|
|
case 25:
|
|
it = cborItem{kind: cFloat, f: halfToFloat(uint16(n))}
|
|
case 26:
|
|
it = cborItem{kind: cFloat, f: float64(math.Float32frombits(uint32(n)))}
|
|
case 27:
|
|
it = cborItem{kind: cFloat, f: math.Float64frombits(n)}
|
|
default:
|
|
return it, 0, fmt.Errorf("%w: unsupported simple value %d", errCBOR, ai)
|
|
}
|
|
default:
|
|
return it, 0, fmt.Errorf("%w: unsupported major type %d", errCBOR, major)
|
|
}
|
|
it.raw = b[start:off]
|
|
return it, off, nil
|
|
}
|
|
|
|
func halfToFloat(h uint16) float64 {
|
|
sign := 1.0
|
|
if h&0x8000 != 0 {
|
|
sign = -1
|
|
}
|
|
exp := int(h>>10) & 0x1f
|
|
frac := float64(h & 0x3ff)
|
|
switch exp {
|
|
case 0:
|
|
return sign * math.Ldexp(frac, -24)
|
|
case 31:
|
|
if frac == 0 {
|
|
return math.Inf(int(sign))
|
|
}
|
|
return math.NaN()
|
|
}
|
|
return sign * math.Ldexp(frac+1024, exp-25)
|
|
}
|
|
|
|
// decodeMMUR1 parses a stored record and rebuilds its signed bytes.
|
|
func decodeMMUR1(b []byte) (*mmur1Record, error) {
|
|
if len(b) == 0 || b[0] != 0x87 {
|
|
return nil, fmt.Errorf("%w: want array(7)", errCBOR)
|
|
}
|
|
off := 1
|
|
items := make([]cborItem, 7)
|
|
sigStart := 0
|
|
for i := range items {
|
|
if i == 6 {
|
|
sigStart = off
|
|
}
|
|
it, next, err := readItem(b, off)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items[i], off = it, next
|
|
}
|
|
if off != len(b) {
|
|
return nil, fmt.Errorf("%w: trailing bytes", errCBOR)
|
|
}
|
|
for i, want := range []cborKind{cText, cText, cText} {
|
|
if items[i].kind != want {
|
|
return nil, fmt.Errorf("%w: cid/uid/key must be text", errCBOR)
|
|
}
|
|
}
|
|
if items[4].kind != cInt || items[4].i < 0 {
|
|
return nil, fmt.Errorf("%w: version must be an unsigned int", errCBOR)
|
|
}
|
|
if items[5].kind != cInt {
|
|
return nil, fmt.Errorf("%w: ts must be an int", errCBOR)
|
|
}
|
|
if items[6].kind != cBytes {
|
|
return nil, fmt.Errorf("%w: sig must be bytes", errCBOR)
|
|
}
|
|
signed := make([]byte, 0, len(mmur1Domain)+sigStart)
|
|
signed = append(signed, mmur1Domain...)
|
|
signed = append(signed, 0x86)
|
|
signed = append(signed, b[1:sigStart]...)
|
|
return &mmur1Record{
|
|
CID: items[0].s, UID: items[1].s, Key: items[2].s,
|
|
Value: items[3],
|
|
Version: uint64(items[4].i),
|
|
TS: items[5].i,
|
|
Sig: items[6].bytes,
|
|
Signed: signed,
|
|
}, nil
|
|
}
|
|
|
|
// envelopeAAD is cid ‖ 0x1F ‖ uid ‖ 0x1F ‖ key ‖ 0x1F ‖ n_be(8) — n was the
|
|
// MMUR1 version and is the MMUR2 ts.
|
|
func envelopeAAD(cid, uid, key string, n uint64) []byte {
|
|
b := make([]byte, 0, len(cid)+len(uid)+len(key)+11)
|
|
b = append(b, cid...)
|
|
b = append(b, 0x1f)
|
|
b = append(b, uid...)
|
|
b = append(b, 0x1f)
|
|
b = append(b, key...)
|
|
b = append(b, 0x1f)
|
|
return binary.BigEndian.AppendUint64(b, n)
|
|
}
|
|
|
|
// openEnvelope decrypts nonce(24) ‖ ciphertext+tag.
|
|
func openEnvelope(aeadKey, env, aad []byte) ([]byte, error) {
|
|
a, err := chacha20poly1305.NewX(aeadKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(env) < chacha20poly1305.NonceSizeX+a.Overhead() {
|
|
return nil, errors.New("envelope too short")
|
|
}
|
|
return a.Open(nil, env[:chacha20poly1305.NonceSizeX], env[chacha20poly1305.NonceSizeX:], aad)
|
|
}
|
|
|
|
// sealEnvelope encrypts plain under a fresh random nonce.
|
|
func sealEnvelope(aeadKey, plain, aad []byte, nonce []byte) ([]byte, error) {
|
|
a, err := chacha20poly1305.NewX(aeadKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]byte, 0, len(nonce)+len(plain)+a.Overhead())
|
|
out = append(out, nonce...)
|
|
return a.Seal(out, nonce, plain, aad), nil
|
|
}
|