Skip to content

Add libmacaroons V2 serialization formats and fix V1 byte-handling bugs - #35

Open
kandalf wants to merge 3 commits into
localmed:masterfrom
kandalf:feature/v2_serialization_support
Open

Add libmacaroons V2 serialization formats and fix V1 byte-handling bugs#35
kandalf wants to merge 3 commits into
localmed:masterfrom
kandalf:feature/v2_serialization_support

Conversation

@kandalf

@kandalf kandalf commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Adds the libmacaroons V2 binary and V2 JSON (v2j) formats, plus automatic
format detection on deserialization, and fixes three pre-existing bugs in the V1
serializers.

The motivation is interoperability. ruby-macaroons has only ever spoken the V1
binary format and a V1 JSON encoding. js-macaroon
speaks neither — it refuses to export V1 binary outright — so the two libraries
have had no binary encoding in common, and only V1 JSON in common at all.
Anything with a browser client is currently forced into base64-wrapped JSON and a
rescue to guess which format arrived.

Compatibility

Nothing an existing caller does changes.

  • New macaroons still default to version 1. V2 is opt-in per macaroon via
    Macaroon.new(..., version: 2), so upgrading emits identical bytes.
  • BinarySerializer#serialize still returns base64; #deserialize still accepts
    it. #serialize_raw is new, for callers who want the wire bytes.
  • JsonSerializer is unchanged in output, including the "vid":null,"cl":null
    keys.
  • Macaroon.from_binary / from_json accept everything they did before, and now
    also V2, raw bytes, and base64-wrapped input. Strictly a superset.

Released as 1.1.0 rather than 2.0.0 on that basis.

What's added

BinaryV2Serializer version byte, then typed uvarint-length-prefixed fields with EOS markers (libmacaroons/v2.c)
JsonV2Serializer same fields as plain strings, or base64 under key + "64" when not valid UTF-8
Macaroon.from_serialized detects V1/V2 and binary/JSON from the data, and unwraps a base64 layer if present

No cryptographic changes. HMAC-SHA256, the macaroons-key-generator derived key,
the keyedHash2 third-party construction and the zero-key request binding are
identical across libmacaroons, js-macaroon and this library, and libmacaroons
applies them regardless of format. Only the framing differs.

Bug fixes (first commit, independent of V2)

7e879ea stands alone and can be taken without the rest:

  1. BinarySerializer sized packets with String#length. A multibyte
    identifier produced a length prefix smaller than the bytes emitted, corrupting
    the stream. deserialize had the matching problem, slicing by character.
  2. A packet header claiming zero length looped forever. The read index never
    advanced, so from_binary hung instead of raising — reachable from
    caller-supplied input.
  3. JsonSerializer#serialize used caveats.map!, mutating @caveats in
    place and replacing each third-party verification_id with its base64 form.
    Calling serialize_json twice double-encoded the vid and produced a macaroon
    that could not be verified.

Also widens vid decoding: js-macaroon writes V1 JSON vids as url-safe unpadded
base64, which Base64.strict_decode64 rejects. Decoding now accepts either
alphabet, padded or not. Output is unchanged — still standard and padded, which
the stricter Go and Python readers require.

Tests

spec/integration_spec.rb is untouched. New coverage lives in
spec/serialization_spec.rb: 27 → 105 examples.

The V1 and V2 expectations are copied verbatim from libmacaroons' own suite
(test/unit/*.vtest and test/unit/serialization_{1,2,3}). There is no macaroon
specification for serialization — the paper defines the cryptographic
construction and says nothing about wire formats — so libmacaroons is the de
facto definition and its vectors are the closest thing to a conformance suite
that exists.

Reproduced as literals, so no new dependencies and nothing outside the repo
is required to run the suite.

One divergence surfaced while doing this: V2 JSON with no caveats. libmacaroons
emits "c":[], js-macaroon omits the key. Both readers accept either; this
follows libmacaroons.

Round trips were additionally verified against js-macaroon in both directions,
including third-party caveat vids — those cross intact because RbNaCl::SimpleBox
and tweetnacl secretbox agree on nonce-then-ciphertext layout. That harness
needs Node, so it is deliberately not part of the repo.

Note on CI

.travis.yml is untouched and points at Ruby versions predating this work, so CI
will not run here. Happy to add a GitHub Actions workflow, in this PR or a
separate one, if that would be useful.

🤖 Generated with Claude Code

kandalf and others added 3 commits August 6, 2026 18:01
Three defects, each reachable from Macaroon.from_binary/from_json on
caller-supplied input:

1. BinarySerializer sized its packets with String#length, so a multibyte
   identifier or caveat produced a length prefix smaller than the bytes
   actually emitted, corrupting the packet stream. deserialize had the
   matching problem, slicing the decoded stream by character.

2. A packet header claiming zero length left the read index unchanged, so
   deserialize spun forever instead of raising. Headers are now validated
   as four hex digits with a length that can at least cover the header and
   the trailing newline.

3. JsonSerializer#serialize used caveats.map!, mutating @Caveats in place
   and replacing each third-party caveat's verification_id with its base64
   form. Calling serialize_json twice therefore double-encoded the vid and
   produced a macaroon that could not be verified.

Also widens vid decoding on the JSON side. js-macaroon writes V1 JSON vids
as url-safe unpadded base64, which Base64.strict_decode64 rejects outright;
decoding now accepts either alphabet, padded or not, matching js-macaroon's
base64ToBytes and libmacaroons' b64_pton. Output is unchanged — still
standard and padded, which the stricter Go and Python readers require.

Wire-facing values are funnelled through Utils.binary on the way in and
Utils.readable on the way out, so lengths are always measured in bytes
while identifiers and predicates still compare equal to ordinary UTF-8
Strings — Verifier#satisfy_exact depends on the latter.

RawMacaroon.build lets the deserializers rebuild a macaroon from signed
parts without inventing a placeholder key and discarding a derived
signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ruby-macaroons has only ever spoken the V1 binary format (libmacaroons/v1.c)
and a V1 JSON encoding. js-macaroon speaks neither: it refuses to export V1
binary outright, so the two libraries have had no binary encoding in common
and only V1 JSON in common at all.

This adds the two V2 encodings from libmacaroons/v2.c:

  BinaryV2Serializer  a version byte followed by typed, uvarint-length
                      prefixed fields, with EOS markers closing the header,
                      each caveat and the caveat list.

  JsonV2Serializer    the same field set, each written as a plain string
                      under its short key, or as url-safe unpadded base64
                      under key + "64" when the bytes are not valid UTF-8.

No cryptographic changes are involved. HMAC-SHA256, the
macaroons-key-generator derived key, the keyedHash2 third-party
construction and the zero-key request binding are identical across
libmacaroons, js-macaroon and this library, and libmacaroons applies them
regardless of format. Only the framing differs.

Macaroon.from_serialized detects the encoding from the data, the way
macaroon_deserialize does in libmacaroons and importMacaroon does in
js-macaroon: V1 binary, V1 JSON, V2 binary or V2 JSON. It also unwraps a
base64 layer if one is present, since macaroons arriving over HTTP almost
always have one. from_binary and from_json remain as aliases, so callers
that used to try one and rescue into the other no longer need to.

New macaroons still default to version 1, so upgrading changes no bytes for
any existing caller. Version 2 is opt-in per macaroon and is required to
exchange binary macaroons with js-macaroon. A macaroon parsed from the wire
re-serializes in the version it arrived in, so a service can accept both and
answer in kind.

The V2 expectations in the specs are taken verbatim from js-macaroon's
test/serialize-binary.js and test/serialize-json.js, so they act as an
interoperability oracle rather than a record of whatever this library
happens to emit. Round trips through js-macaroon were verified in both
directions, including third-party caveat vids, which cross the boundary
intact because RbNaCl::SimpleBox and tweetnacl secretbox agree on
nonce-then-ciphertext layout.

BinarySerializer#serialize and #deserialize keep their existing contract:
serialize still returns base64, deserialize still accepts it. serialize_raw
exposes the wire bytes for callers that want to hand them to libmacaroons
directly, and deserialize now additionally accepts the raw packet stream —
the two forms are unambiguous, since packets open with four hex digits and
their base64 always opens "MDAx".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a Serializing section covering the four encodings, the version:
argument and its default, and the fact that Macaroon.from_serialized
detects the format from the data.

Calls out that js-macaroon cannot read or write V1 binary, since that is
the detail which decides whether a caller needs version: 2.

1.1.0 rather than 2.0.0: the V2 formats are additive and the default
version is unchanged, so no existing caller emits different bytes after
upgrading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant