Skip to content

Check the device certificate in an uploaded KeyPackage (GRYT-1509) - #243

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-1509-keypackage-certs
Sep 28, 2026
Merged

sivert-io merged 1 commit into
mainfrom
claude/GRYT-1509-keypackage-certs

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

What to look at

  • Rebased onto main after Refuse and prune expired KeyPackages (GRYT-1510) #242, Leave a line for apps from before MLS (GRYT-1508) #244 and Somewhere to publish the MLS person key binding (GRYT-1515) #245. parseKeyPackage checks Refuse and prune expired KeyPackages (GRYT-1510) #242's lifetime first and then the signatures. The lifetime is a few integer compares where the rest is two Ed25519 verifications, and a device whose clock is off gets told about its clock, with serverTime, rather than about a signature. @gryt/crypto 0.7.0 was already on main from Somewhere to publish the MLS person key binding (GRYT-1515) #245, so package.json and yarn.lock don't change here.
  • The certificate is read by @gryt/crypto 0.7.0, pinned exactly, through require("@gryt/crypto/mls-device-certificate"). That's the package's ./* export, so crypto's index and ts-mls's index stay out, and it leans on Node's require(esm) like mlsWire.ts already does.
  • crypto's readDeviceCertificate wants the scope to expect. The server can't know it, so claimedScope reads the scope out of the certificate (version byte, u16 length, UTF-8) and passes it back. Everything else, the format and the signature, is crypto's.
  • The bytes get copied into a plain Uint8Array first. crypto reads a Node Buffer's lengths off the shared pool and refuses a good certificate, and ts-mls hands the credential back as a Buffer when it decoded one. That's GRYT-1520, filed against crypto.
  • The KeyPackage's own signature still goes through ts-mls's verifyKeyPackage with Ed25519 from node:crypto, since crypto 0.7.0 only exports that check inside readMlsKeyPackage, which also wants the scope and applies the lifetime rules from Refuse and prune expired KeyPackages (GRYT-1510) #242.
  • The scope isn't checked. A client builds it from its own pin: srv: plus the first key id it saw for this server, or the bare address if it has no pin. The server can't always know which, and a wrong guess would lock that client out of MLS here for good. Clients check the scope themselves.
  • mls:keypackages:publish has three new refusals: invalid_key_package when the leaf key didn't sign it, invalid_device_certificate, and device_mismatch when the certificate names another device than the upload.

What's in it

On upload the server now does what crypto's readMlsKeyPackage does, minus the scope. The KeyPackage has to be signed by its own leaf key. Its credential has to hold a device certificate the person key signed, for that same leaf key and for the deviceId it's uploaded under.

The rest of the chain, that the person key is bound by the member's identity, needs the binding, and the server never gets it. That's GRYT-1515. Once the binding is published the upload can check it too.

Tests

The handler tests now give each device a real certificate, signed with crypto's signDeviceCertificate. The fixture test still reads a certificate a crypto build signed at 272bb78, and reads it again as a Buffer. New ones: crypto's own certificate reads and names its device, a certificate with one byte changed is refused, a credential that's just a name is refused, a certificate moved onto another leaf key is refused, a KeyPackage its leaf didn't sign is refused, and an upload under another device's id gets device_mismatch. Full suite: 1757 pass.

Task: GRYT-1509. Docs: Gryt-chat/docs#141.

🤖 Generated with Claude Code

On upload the server now does what @gryt/crypto's readMlsKeyPackage does,
minus the scope. After the lifetime check from #242, the KeyPackage has to
be signed by its own leaf key. Its credential has to hold a device
certificate the person key signed, for that same leaf key and for the
deviceId the upload is under (device_mismatch otherwise).

The lifetime goes first. It's a few integer compares, where the rest is two
Ed25519 verifications, so junk that's also expired costs nothing. And a
device whose clock is off hears about its clock, with serverTime to retry
against, and not about a signature that was never the problem.

The certificate is read by @gryt/crypto 0.7.0, already on main from #245,
through its ./* export so neither index loads. crypto's reader wants the
scope to expect, and the server can't know which one a client used, so it
passes back the scope the certificate names. Clients check it against their
pin. The bytes are copied first, because crypto reads a Node Buffer's
lengths off the shared pool (GRYT-1520). The tests sign certificates with
crypto, and still read one a crypto build signed at 272bb78.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@sivert-io
sivert-io force-pushed the claude/GRYT-1509-keypackage-certs branch from 87a20b9 to 9a6c491 Compare September 28, 2026 08:13
@sivert-io
sivert-io merged commit 32ef7a2 into main Sep 28, 2026
4 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-1509-keypackage-certs branch September 28, 2026 08:19
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