İçeriğe geç
codebiy
← Yazı masasına dön

Mac'te çevrimdışı Pro lisansı: hesap yok, lisans sunucusu yok

Arvlin Pro lisansı, Ed25519 ile imzalanmış tek satır metin: veritabanı olmadan düzenleniyor, çevrimdışı doğrulanıyor. Yayın günü Polar'ın webhook teslimatları 403 ile reddedildi. İkinci teslim yolu webhook'tan geçmiyor.

Arvlin, yapay zekâ araçlarıyla çalışan geliştiriciler için Mac uygulamamız: prompt ve skill kütüphanesi, yerel geliştirme servislerini ve yapay zekâ kullanımını gösteren bir ekran ve silinecek her şeyin önce gösterildiği bir Mac temizliği. Free planda hesap da süre sınırı da yok, Pro ise tek seferlik bir satın alma. Pro'yu almak da bunu değiştirmiyor: oturum açma yok, etkinleştirme sunucusu yok, bizim tarafımızda müşteri veritabanı yok.

Lisans tek satırlık bir metin. Pro'yu satan site onu siparişten hesaplıyor ve hiçbir şey saklamıyor, uygulama da çevrimdışı doğruluyor. Uygulama Swift 6 ile, macOS 14 ve sonrası için yazıldı ve doğrulamayı CryptoKit ile yapıyor. Site Next.js 16 ile çalışıyor, ödemeyi Polar üzerinden, @polar-sh/sdk ile alıyor ve imzayı Node'un crypto modülüyle atıyor. Sitenin yayına çıktığı 15 Eylül 2026'da iki teslim yolundan biri bozuldu: webhook'umuz Polar'ın imzalarını reddetti ve yayın commit'inden 28 dakika sonra gelen düzeltmeye kadar hiçbir lisans e-postası çıkmadı.

Lisans biçimi: bir payload ve bir imza

Lisans, noktayla birleştirilmiş iki base64url string'i: bir JSON payload'u ve o payload'a atılmış Ed25519 imzası. Lisansı, Pro'yu satan sitedeki tek bir fonksiyon düzenliyor:

/// Produces the token ProLicenseVerifier accepts. Ed25519 is deterministic, so
/// the same order always yields the same license: the thank-you page and the
/// webhook email agree without any stored state.
export function issueLicense(
  order: { id: string; createdAt: Date },
  key: KeyObject,
): string {
  if (!LICENSE_ID.test(order.id))
    throw new Error("Order id cannot be a license id.");
  if (Number.isNaN(order.createdAt.getTime()))
    throw new Error("Order date is invalid.");
  const payload = Buffer.from(
    JSON.stringify({
      issuedAt: order.createdAt.toISOString(),
      licenseID: order.id,
      product: "arvlin-pro",
      version: 1,
    }),
  );
  return `${payload.toString("base64url")}.${sign(null, payload, key)
    .toString("base64url")}`;
}

Payload'da dört alan var. Müşteri adı, e-posta adresi ya da cihaz bilgisi yok. Uygulamada yalnızca 32 baytlık public key duruyor ve uygulama imzayı CryptoKit'in Curve25519.Signing.PublicKey tipiyle doğruluyor.

Private key uygulamada yok. Site onu build sırasında değil, her istekte okuyor. Bu yüzden sitenin Docker image'ı private key olmadan da build ediliyor.

İllüstrasyon: ayaklı büyütecin altında kırmızı balmumu mühürlü boş bir kâğıt bilet duruyor, yanında da ucu boşta bir ağ kablosu var. Lisans, imzalı tek satırlık bir metin: uygulama onu çevrimdışı doğruluyor.

Site lisansların kaydını tutmuyor. Lisans kimliği Polar'ın sipariş ID'si, düzenlenme zamanı da siparişin oluşturulma zamanı. EdDSA imzaları deterministik (RFC 8032, bölüm 8.2), yani bir sipariş, ne zaman ve nerede hesaplanırsa hesaplansın, hep aynı metni üretir.

Bu özellik, düzenlenmiş lisansların tutulduğu bir tablonun yerini alıyor. Aranacak kayıt, senkron tutulacak durum yok. Alıcı e-postayı kaybederse yeniden gönderdiğimiz lisans, siparişten yeniden hesaplanan aynı metindir.

Kimlik kuralı iki tarafta da aynı: harfler, rakamlar ve ._:-, en fazla 128 karakter. Site, uygulamanın kabul etmeyeceği bir kimliği imzalamayı reddediyor.

Lisansın teslimi: sayfa ve e-posta

Lisans alıcıya iki kez, birbirinden bağımsız iki yoldan ulaşıyor. Ödeme tamamlanınca Polar, tarayıcıyı sitemizdeki bir sayfaya yönlendiriyor. Sayfa, o işlemin ödenmiş siparişini Polar'dan istiyor ve lisansı oracıkta imzalıyor. Sipariş henüz ödenmiş görünmüyorsa bunu söylüyor ve yeniden denemeyi öneriyor. Bundan ayrı olarak Polar, sipariş ödendiğinde webhook'umuzu çağırıyor, webhook da aynı lisansı e-postayla gönderiyor.

Webhook'un bilerek seçtiğimiz bir davranışı var. E-posta gönderimi hata fırlatırsa istek 500 ile sonuçlanıyor ve Polar olayı yeniden gönderiyor: başarısız teslimatı katlanarak artan aralıklarla en fazla on kez yeniden deniyor.

Yayın günündeki hata: 403 ile reddedilen Polar webhook imzaları

Sitenin yayına çıktığı 15 Eylül 2026'da e-posta yolu çalışmadı. Teslimatları @polar-sh/sdk 0.49 içindeki validateEvent ile doğruluyorduk. Bu fonksiyon, HMAC anahtarı olarak secret metninin tamamını UTF-8 baytlarıyla kullanıyor. Bu, Polar'ın eski imza şeması.

Polar, 8 Eylül 2026 ve sonrasında üretilen secret'larda Standard Webhooks tanımına göre imzalıyor ve secret olduğu gibi veriliyor. İki kural da Polar'ın teslimat kılavuzunda yazıyor, bizim secret'ımız da yeniydi. Hiçbir teslimat doğrulamadan geçemedi, route'umuz 403 döndürdü ve hiçbir lisans e-postası çıkmadı.

Düzeltme, yayın commit'inden 28 dakika sonra geldi. Artık teslimatı standardwebhooks kütüphanesiyle, iki anahtarla da doğruluyoruz. Düzeltmenin testi bir teslimatı iki imza şemasıyla da imzalıyor, yanlış secret'ın ve değiştirilmiş gövdenin reddedildiğini kontrol ediyor. Reddedilen teslimatların ilk kez nasıl fark edildiğini not almadık. Aynı kılavuza göre Polar'ın kendi SDK'ları 1.0.0-alpha.19 sürümünden itibaren iki anahtarı da deniyor.

Sayfa webhook'tan hiç geçmiyor, bu yüzden reddedilen bir teslimat onu etkilemiyor. İki yolun gerekçesi de bu: aynı metin, farklı nedenlerle bozulan iki yerden geliyor.

Uygulamadaki doğrulayıcı

Uygulama önce imzayı doğruluyor, sonra payload'a yine de güvenmiyor:

// A closed schema avoids silently interpreting misspelled expiry fields
// as a lifetime license.
guard let object = try? JSONSerialization.jsonObject(with: payload)
        as? [String: Any],
      Set(object.keys).isSubset(of: [
          "version", "product", "licenseID", "issuedAt", "expiresAt"
      ]) else {
    throw ProLicenseError.invalidPayload
}

Lisans biçiminde isteğe bağlı bir bitiş tarihi alanı var, sattığımız lisanslarda ise bitiş tarihi yok. Bilinmeyen bir anahtarın atlanmayıp hata sayılması bu yüzden şart.

Tarihler de aynı muameleyi görüyor. Doğrulayıcı, eksiksiz bir RFC 3339 zaman damgasını kendi deseniyle eşleştiriyor ve sonucu takvimden geçirip geri okuyor. Sistemin ISO8601DateFormatter sınıfı bir lisans kontrolü için fazla hoşgörülü: macOS 27'de 30 Şubat'ı 2 Mart diye okudu ve geçerli bir zaman damgasının ardına eklenmiş başka metni de kabul etti.

Doğrulayıcı boyutu ve zamanı da sınırlıyor. Metin en fazla 64 KiB, imza tam 64 bayt, public key tam 32 bayt. Base64url kanonik ve dolgusuz olmak zorunda. Düzenlenme zamanı Mac'in saatinden en fazla beş dakika ileride olabilir. Gelecekten gelen bir lisansın hata mesajında kullanıcıdan Mac'in tarih ve saatini kontrol etmesi isteniyor.

Bir tarafın katılığı, ancak öteki taraf biçimden sapamıyorsa güvenlidir. Lisansı düzenleyen taraf sitede TypeScript, doğrulayan taraf uygulamada Swift. Bu yüzden iki repo'da da aynı test token'ı sabit duruyor. Token, yalnızca test için üretilmiş bir anahtarla yapıldı.

Sitenin testi, sabit bir sipariş için birebir o token'ın üretildiğini kontrol ediyor. Uygulamanın testi ise o token'ın doğrulamadan geçtiğini, beklenen kimliği ve zamanı verdiğini kontrol ediyor. Bir alanın adındaki, tarih biçimindeki ya da kodlamadaki değişiklik, onu yapan tarafta bir testi kırıyor.

Etkinleştirme ve geliştirme build'leri

Uygulamada etkinleştirme, metni Pro ekranına yapıştırmaktan ibaret. Uygulama metni doğruluyor, sonra geçici bir dosyaya yazıp yeniden adlandırarak yalnızca sahibinin okuyabildiği lisans dosyasını oluşturuyor. Yazma başarısız olursa Pro açılmıyor, orada duran lisansın üzerine de yazılmıyor.

Dosya, uygulama açılırken ve çalışırken her 60 saniyede bir yeniden doğrulanıyor. Silinir ya da bozulursa Pro, uygulama yeniden başlatılmadan kapanıyor. Lisansı bir Mac'ten kaldırmak uygulamada tek bir işlem ve kütüphaneyi etkilemiyor.

Pro'yu açan bir debug kısayolu yok. Geliştirme için bir script kendi anahtar çiftini üretiyor, onunla bir lisans düzenliyor ve uygulamayı o public key ile derliyor. Böylece geliştirme build'i de Pro'ya canlıdaki yoldan ulaşıyor.

Lisans koşullarının kapsadıkları

Çevrimdışı kontrolün alternatifi ayrı bir doğrulama sistemi, yani uygulamanın ulaşmak zorunda olduğu bir sunucu. Onu kurmadık, ama bir şartla: satış ve iade politikası kontrole uymalı. Bizimki buna göre yazıldı. Bir lisans tek kullanıcı içindir ve en fazla iki Mac'te kullanılır, paylaşımı ve iadeyi de satış koşulları düzenliyor.

Karşılığında uygulama, Pro'yu açmak için hiçbir sunucuya ulaşmak zorunda kalmıyor.

Yine yapacaklarımız ve değiştireceğimiz tek şey

  • Lisansı siparişin saf bir fonksiyonu yapın. Saklanan durum yoksa kaybedilecek durum da yoktur.
  • Lisansı, farklı nedenlerle bozulan iki yoldan teslim edin.
  • Biçimi kullanan her repo'da aynı test token'ını sabit tutun.
  • Şemayı kapalı tutun. Lisansta cömert varsayılan, tehlikeli olandır.
  • Geliştirmeyi canlıdaki yoldan, bir geliştirme anahtarıyla yapın.
  • Satış ve iade koşullarını kontrolle birlikte, ona uyacak biçimde yazın.

Değiştireceğimiz tek şey: yayından önce, Polar'ın canlıda imzaladığı biçimde imzalanmış bir teslimatı webhook kontrolünden geçirirdik.

OKUMAYA DEVAM ETTikTok onayladı, herkese açık gönderi gitmedi: Direct Post audit'i ↗App Store Connect salesReports, rapor saati gelmeden “satış yok” dedi ↗