test_message_protection.mx raw

   1  //go:build !wasm
   2  
   3  package mls
   4  
   5  import "errors"
   6  
   7  // TestMessageProtection validates framing crypto (sign/verify + encrypt/decrypt)
   8  // against RFC 9420 test vectors (cipher_suite 3, message-protection.json index 2).
   9  //
  10  // Public messages (proposal, commit):
  11  //   - Unmarshal mlsMessage → publicMessage
  12  //   - Verify signature with signature_pub
  13  //   - Verify membership tag with membership_key
  14  //   - Compare raw framed content body to vector's proposal/commit bytes
  15  //
  16  // Private messages (proposal, commit, application):
  17  //   - Unmarshal mlsMessage → privateMessage
  18  //   - Decrypt sender data with sender_data_secret
  19  //   - Derive secret tree (numLeaves=2, encryption_secret)
  20  //   - Advance handshake/application ratchet to senderData.generation
  21  //   - Decrypt content
  22  //   - Verify signature, compare raw content body to vector bytes
  23  func TestMessageProtection() error {
  24  	cs := CipherSuite0x0003
  25  
  26  	groupID := GroupID(hexb("fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf"))
  27  	epoch := uint64(1184274)
  28  	treeHash := hexb("5ae6ee5f282786b753d55f15938248f6212c69b2f677319e5fcdb2ce0bfbf48a")
  29  	confirmedTH := hexb("0ba35f0d36007bc44df41db074b9d98a1314e3c690cb345ceeae152fbe5b6afe")
  30  	signaturePub := signaturePublicKey(hexb("b1d7f2e45d6c94fa8bff3b63308bbba38024c5b70c22234834ee24294b6e0a28"))
  31  	encryptionSecret := hexb("ce02c4dd80a8ed57503e3a50ec11b926a549d65e840417c0635427a838b3cf29")
  32  	senderDataSecret := hexb("85fc31fac878dbe2850e30a8fa88bdc63dacd890756aade03433791b50a510f5")
  33  	membershipKey := hexb("82f73c829938c5d1a19e19de2b2c109214455ee6671cf918af426f364162678e")
  34  
  35  	proposalBytes := hexb("000300000002")
  36  	proposalPriv := hexb("0001000220fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf000000000012121202001cc15e1b90a18448376191078a6b66567941ce2472f110def1215eb1114058f9bf6e2c2dd19ff11b450ce94d9990ca3dbaf94edf8873c0f7cbf8feeefd362ac674b32d4c1c6ca646b8282fec7aaa51dcdd402345792aa77d98eaa97da5bf13969e44845f33114c10e1edd765d1c738059757570366687b")
  37  	proposalPub := hexb("0001000120fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf00000000001212120100000001000200030000000240402aef68e55e2092800105a0a963c84afb2039093f5431f9c59fce19d3f2bca20689a617deb8fa59085f21b609649b0373e62c0c099e9bfc66f939e623a851d3022048451581d2796c68ca95ddbf06fe4cc69f5b1360f12dce5947e095ab26c259a3")
  38  
  39  	commitBytes := hexb("4046010004012017e89d9ff20be622654e2b82b1e55d5b51c9e01d3b02cf0fa179c14e8697c11620d3ad3e549ffe3fe157d4d2e6627b8d35be6b5b8f37ffb10ed358bfb47c6c829500")
  40  	commitPriv := hexb("0001000220fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf000000000012121203001c71db9299c28f6e4feec4ddd63660f8ee186769ba51f2721953c48b9440bc32b3c5b162267da077973a9966c1c1bf6406d4326d9b0649bffeceec9ebe4dcf8603aa291254ce1954471cb01670b8e8e2f111ed54f8c818b348bd0300eb174602b484899f2fa95bbebdae6807de23355e2a988cf30cdeb2327759b2274205f485ff15b668b00a4f36ead53e21091be1133c2dd9a635bd6a942509921e33647e864a26af80aa5e54e15e695d2156209c2da2a7b8fe1938632192fce5e75736249c2fb6a7c2af46b29bfb5a83699d568f6b5614bf4edca36dc290f71d")
  41  	commitPub := hexb("0001000120fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf0000000000121212010000000100034046010004012017e89d9ff20be622654e2b82b1e55d5b51c9e01d3b02cf0fa179c14e8697c11620d3ad3e549ffe3fe157d4d2e6627b8d35be6b5b8f37ffb10ed358bfb47c6c82950040402ad9df6c6e068cfb07cd8a6e49455f256ca630341744128cdc2bd180c8dd76b8e2b3affc5e8c0b3a70d55e349e46adf95e2641299b6c1ac78336e69ec243860520b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad20aa0af8115702fcd155306e3f0091dbc40c772a97547ee9398157106ad10e9bf1")
  42  
  43  	applicationBytes := hexb("725b6517ff1e5ed74224de23777a9b5f4a2dd74fe4a816d19ea71baa069d74c0975b7f10e61b7c3ef339")
  44  	applicationPriv := hexb("0001000220fdbada2ddce02e7493673caab793308bdeb5cdb0558f151f647ab6e2ca0c63cf000000000012121201001c22d286c12043af1e8093bd213bf707b9b675ee98a171d0eeb206de91407d9d099e17b2aa1de37065098feb8efa598721128ec5bada3a6a07e06123eb61ced1dd265a0e7468f05f39b549ede80606f4795425de91acaee0b0bd840d0eabd284ae30adf04a5bf97027180836adee1203636c2cfd36f477747e9f0549dc99f5b3ef3a3db4f4def426625757365cf871061383a6dbd516127a11f83cc4")
  45  
  46  	ctx := &groupContext{
  47  		version:                 protocolVersionMLS10,
  48  		cipherSuite:             cs,
  49  		groupID:                 groupID,
  50  		epoch:                   epoch,
  51  		treeHash:                treeHash,
  52  		confirmedTranscriptHash: confirmedTH,
  53  	}
  54  
  55  	// --- Public messages ---
  56  	if err := checkPublicMessage(ctx, proposalPub, proposalBytes, contentTypeProposal, signaturePub, membershipKey, "proposal"); err != nil {
  57  		return err
  58  	}
  59  	if err := checkPublicMessage(ctx, commitPub, commitBytes, contentTypeCommit, signaturePub, membershipKey, "commit"); err != nil {
  60  		return err
  61  	}
  62  
  63  	// --- Private messages ---
  64  	secTree, err := deriveSecretTree(cs, numLeaves(2), encryptionSecret)
  65  	if err != nil {
  66  		return errors.New("deriveSecretTree: " | err.Error())
  67  	}
  68  
  69  	if err := checkPrivateMessage(ctx, secTree, senderDataSecret, proposalPriv, proposalBytes, contentTypeProposal, signaturePub, "proposal_priv"); err != nil {
  70  		return err
  71  	}
  72  	if err := checkPrivateMessage(ctx, secTree, senderDataSecret, commitPriv, commitBytes, contentTypeCommit, signaturePub, "commit_priv"); err != nil {
  73  		return err
  74  	}
  75  	if err := checkPrivateMessage(ctx, secTree, senderDataSecret, applicationPriv, applicationBytes, contentTypeApplication, signaturePub, "application_priv"); err != nil {
  76  		return err
  77  	}
  78  
  79  	return nil
  80  }
  81  
  82  func checkPublicMessage(ctx *groupContext, wire, wantBody []byte, wantCT contentType, sigPub signaturePublicKey, membershipKey []byte, tag string) error {
  83  	var msg mlsMessage
  84  	if err := unmarshalRaw(wire, &msg); err != nil {
  85  		return errors.New(tag | ": unmarshal mlsMessage: " | err.Error())
  86  	}
  87  	if msg.wireFormat != wireFormatMLSPublicMessage || msg.publicMessage == nil {
  88  		return errors.New(tag | ": wireFormat is not public")
  89  	}
  90  	pub := msg.publicMessage
  91  	if pub.content.contentType != wantCT {
  92  		return errors.New(tag | ": unexpected contentType")
  93  	}
  94  
  95  	authContent := pub.authenticatedContent()
  96  	if !authContent.verifySignature(sigPub, ctx) {
  97  		return errors.New(tag | ": verifySignature failed")
  98  	}
  99  
 100  	if !pub.verifyMembershipTag(membershipKey, ctx) {
 101  		return errors.New(tag | ": verifyMembershipTag failed")
 102  	}
 103  
 104  	// Compare the content body (proposal/commit wire bytes) against the vector.
 105  	var w Writer
 106  	switch wantCT {
 107  	case contentTypeProposal:
 108  		pub.content.proposal.marshal(&w)
 109  	case contentTypeCommit:
 110  		pub.content.commit.marshal(&w)
 111  	default:
 112  		return errors.New(tag | ": unsupported contentType for public message")
 113  	}
 114  	got, err := w.bytes()
 115  	if err != nil {
 116  		return errors.New(tag | ": marshal content body: " | err.Error())
 117  	}
 118  	if !bytesEqual(got, wantBody) {
 119  		return errors.New(tag | ": content body mismatch: got " | hexenc(got) | " want " | hexenc(wantBody))
 120  	}
 121  	return nil
 122  }
 123  
 124  func checkPrivateMessage(ctx *groupContext, tree secretTree, senderDataSecret, wire, wantBody []byte, wantCT contentType, sigPub signaturePublicKey, tag string) error {
 125  	cs := ctx.cipherSuite
 126  	var msg mlsMessage
 127  	if err := unmarshalRaw(wire, &msg); err != nil {
 128  		return errors.New(tag | ": unmarshal mlsMessage: " | err.Error())
 129  	}
 130  	if msg.wireFormat != wireFormatMLSPrivateMessage || msg.privateMessage == nil {
 131  		return errors.New(tag | ": wireFormat is not private")
 132  	}
 133  	priv := msg.privateMessage
 134  	if priv.contentType != wantCT {
 135  		return errors.New(tag | ": unexpected contentType")
 136  	}
 137  
 138  	sd, err := priv.decryptSenderData(cs, senderDataSecret)
 139  	if err != nil {
 140  		return errors.New(tag | ": decryptSenderData: " | err.Error())
 141  	}
 142  
 143  	// Advance the appropriate ratchet (handshake for proposal/commit, application for application)
 144  	// to the sender's generation.
 145  	label := ratchetLabelFromContentType(wantCT)
 146  	secret, err := tree.deriveRatchetRoot(cs, sd.leafIndex.nodeIndex(), label)
 147  	if err != nil {
 148  		return errors.New(tag | ": deriveRatchetRoot: " | err.Error())
 149  	}
 150  	for secret.generation < sd.generation {
 151  		secret, err = secret.deriveNext(cs)
 152  		if err != nil {
 153  			return errors.New(tag | ": deriveNext: " | err.Error())
 154  		}
 155  	}
 156  
 157  	content, err := priv.decryptContent(cs, secret, sd.reuseGuard)
 158  	if err != nil {
 159  		return errors.New(tag | ": decryptContent: " | err.Error())
 160  	}
 161  
 162  	// Reconstruct an authenticatedContent from the decrypted privateMessageContent
 163  	// so we can verify the signature.
 164  	fc := &framedContent{
 165  		groupID:           priv.groupID,
 166  		epoch:             priv.epoch,
 167  		sender:            sender{senderType: senderTypeMember, leafIndex: sd.leafIndex},
 168  		authenticatedData: priv.authenticatedData,
 169  		contentType:       priv.contentType,
 170  		applicationData:   content.applicationData,
 171  		proposal:          content.proposal,
 172  		commit:            content.commit,
 173  	}
 174  	authContent := &authenticatedContent{
 175  		wireFormat: wireFormatMLSPrivateMessage,
 176  		content:    *fc,
 177  		auth:       content.auth,
 178  	}
 179  	if !authContent.verifySignature(sigPub, ctx) {
 180  		return errors.New(tag | ": verifySignature failed")
 181  	}
 182  
 183  	// Compare the content body to the vector.
 184  	var w Writer
 185  	switch wantCT {
 186  	case contentTypeApplication:
 187  		w.addBytes(content.applicationData)
 188  	case contentTypeProposal:
 189  		content.proposal.marshal(&w)
 190  	case contentTypeCommit:
 191  		content.commit.marshal(&w)
 192  	default:
 193  		return errors.New(tag | ": unsupported contentType")
 194  	}
 195  	got, err := w.bytes()
 196  	if err != nil {
 197  		return errors.New(tag | ": marshal content body: " | err.Error())
 198  	}
 199  	if !bytesEqual(got, wantBody) {
 200  		return errors.New(tag | ": content body mismatch: got " | hexenc(got) | " want " | hexenc(wantBody))
 201  	}
 202  	return nil
 203  }