Skip to content

feat(license): replace forgeable rotor cipher with Ed25519-signed tokens - #409

Merged
krisarmstrong merged 1 commit into
mainfrom
harden/stem-ed25519-licenses
Jun 8, 2026
Merged

feat(license): replace forgeable rotor cipher with Ed25519-signed tokens#409
krisarmstrong merged 1 commit into
mainfrom
harden/stem-ed25519-licenses

Conversation

@krisarmstrong

Copy link
Copy Markdown
Collaborator

Summary

Replaces Stem's forgeable license scheme with offline-verifiable Ed25519-signed tokens, harmonized with niac (#802) and the keygen tool. The old 16-char rotor-cipher + self-checksum shipped its own generator (GenerateLicenseKey) inside every binary — any copy could mint valid Professional keys. This is the Ed25519 license item from the stem/niac remediation plan (ADR-0007).

Token format (signing.go)

MSN1.<base64url(payload)>.<base64url(signature)>
  • payload = canonical JSON {v, product, code, serial, tier, maxDevices, iat, exp}
  • signature = 64-byte Ed25519 over the exact payload bytes
  • binary embeds only the public key; private key lives solely in the keygen tool

What changed

  • Verifier.Validate checks the signature before interpreting any field, then enforces scheme/version/product==stem/tier/code-pairing (1001→Reflector, 2001→Professional, 3001→Enterprise)/expiry. Tier→feature mapping stays in-binary; deprecated Enterprise tokens keep validating and grant Pro features. TierProfessional/TierTestSuite alias and wire codes preserved.
  • Deleted cipher.go + GenerateLicenseKey (the forgeability vector).
  • Manager gains a verifier field + NewManagerWithVerifier so tests activate ephemeral-key tokens without the production private key.
  • Tests rewired: rotor/checksum/alphanumeric tests removed (tested deleted code); GenerateLicenseKey→signTestKey, ValidateLicenseKey→testVerifier, NewManager→NewManagerWithVerifier. Added forge/tamper rejection + per-tier production-signed contract vectors.
  • FormatKey no longer strips characters (base64url uses -/_).

Security property

TestForgeryRejected/TestForgeryRejectedInternal mint an attacker-key token and assert the production verifier rejects it with ErrLicenseInvalid. Validation stays fully offline (no phone-home).

Pre-launch: no issued keys to honor. Embedded key is a pre-launch key — rotate via keygen before GA (regenerate key + contract vectors together).

Verification

  • go build ./..., go vet ./... clean
  • golangci-lint — 0 issues (license + consumers); one narrow documented unparam exclusion for the signTestKey (string,error) drop-in shape
  • go test -race ./internal/license/ (ok 101s) + ./internal/api/ (ok 512s)

The 16-char rotor-cipher + self-checksum scheme shipped its own generator
(GenerateLicenseKey) inside every Stem binary, so any copy could mint valid
Professional keys. Replace it with offline-verifiable Ed25519-signed tokens
(signing.go), harmonized with the niac/seed format and the keygen tool:

  MSN1.<base64url(payload)>.<base64url(signature)>

The binary embeds only the Ed25519 public key; the private key lives solely in
the keygen tool. Verifier.Validate checks the signature before interpreting any
field, then enforces scheme/version/product==stem/tier/code-pairing/expiry.
Tier->feature mapping stays in-binary; deprecated Enterprise (3001/tier3) tokens
keep validating and grant Pro features. The TierProfessional/TierTestSuite alias
and 1001/2001/3001 wire codes are preserved.

- Delete cipher.go + GenerateLicenseKey (the forgeability vector).
- Manager gains a verifier field + NewManagerWithVerifier so tests activate
  tokens minted with an ephemeral key without the production private key.
- Tests rewired to the signed format: rotor/checksum/alphanumeric tests removed
  (tested deleted code); GenerateLicenseKey->signTestKey, ValidateLicenseKey->
  testVerifier, NewManager->NewManagerWithVerifier; forge/tamper rejection and
  per-tier production-signed contract vectors pin the cross-tool contract.
- FormatKey no longer strips characters (base64url uses '-'/'_').
- ADR-0007 records the decision; one narrow unparam exclusion documents the
  signTestKey (string,error) drop-in shape.

Pre-launch: no issued keys to honor. Embedded key is a pre-launch key; rotate
via keygen before GA.

Verified: go build ./..., go vet ./..., golangci-lint (0 issues, license +
consumers), go test -race ./internal/license/ (ok 101s) + ./internal/api/
(ok 512s).
@krisarmstrong
krisarmstrong enabled auto-merge (squash) June 8, 2026 13:30
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

License Compliance Report

All dependencies pass license compliance checks

Go Dependencies

  • Unknown: 31 package(s)
  • MIT: 26 package(s)
  • BSD-3-Clause: 16 package(s)
  • Apache-2.0: 11 package(s)
  • BSD-2-Clause: 1 package(s)

npm Dependencies

See full report in workflow artifacts

Allowed Licenses: MIT, Apache-2.0, BSD-*, ISC, CC0-1.0, MPL-2.0
Forbidden: GPL, AGPL, SSPL (strong copyleft)

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