Security

Licence keys

Signed licence keys with a customer, features and an expiry date: made with your private key, checked offline, impossible to forge.

Version 1.0.0 · 4 functions · 4 crates

#licensing#signing#payments#keys

Functions

What use license_keys.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use license_keypair, license_make from license_keys (Quillo 0.6).

license_keypair() → map

A new signing pair: { private, public }, base64. Keep private where keys are made (never in the app you ship); put public in the app.

license_make(private_key: str, customer: str, features: list, expires: str) → stror an error

A licence key for customer with features, valid until expires (a date, 2027-12-31, included; empty for ever), signed with private_key. Upper-case letters and digits in groups of five, such as LIC-MFRGG-..., about 130 to 200 characters: paste-able, and readable by license_check alone.

license_check(key: str, public_key: str) → anyor an error

What a key says, checked with public_key: { valid, customer, features, expires, expired, reason }. valid is true only for a key made with the matching private key and not past its date; a typo or a forged key gives valid: false and a reason. Spaces, dashes and lower case in the pasted key do not matter.

license_has(key: str, public_key: str, feature: str) → boolor an error

Whether key is valid and holds feature.

Get it

$ quillo install license_keys               # license_keys.qwer into this folder$ quillo install license_keys --example     # and its example, license_keys.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/license_keys.qwer$ curl -O https://quillolang.org/plugins/files/license_keys.qwe

Then, in your program, all of it or only what you call:

main.qwe
use license_keys.qweruse license_keypair, license_make from license_keys      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ed25519-dalek = "2"rand = "0.8"base64 = "0.22"data-encoding = "2"

Example

license_keys.qwe
// A shop that sells licences, and an app that checks them without calling home.////   quillo run license_keys.qwe      then open http://localhost:8080/buyuse "license_keys.qwer"// LICENSE_PRIVATE_KEY lives on the shop's server only; the app ships with the public key.let PAIR = license_keypair()let PRIVATE = env("LICENSE_PRIVATE_KEY") ?? PAIR.privatelet PUBLIC = env("LICENSE_PUBLIC_KEY") ?? PAIR.publicroute GET /buy {    let key = license_make(PRIVATE, "Acme Ltd", ["pro", "export"], "2099-12-31")?    { key, check: license_check(key, PUBLIC)?, can_export: license_has(key, PUBLIC, "export")?, can_sso: license_has(key, PUBLIC, "sso")? }}route GET /checks {    let good = license_make(PRIVATE, "Acme Ltd", ["pro"], "")?    let old = license_make(PRIVATE, "Old Customer", ["pro"], "2020-01-31")?    let forged = license_make(license_keypair().private, "Acme Ltd", ["pro", "sso"], "")?    {        pasted_lower_case: license_check(lower(good), PUBLIC)?.valid,        typo: license_check(replace(good, slice(good, 10, 11), slice(good, 10, 11) == "A" ? "B" : "A"), PUBLIC)?.reason,        expired: license_check(old, PUBLIC)?.reason,        forged: license_check(forged, PUBLIC)?.reason,    }}
$ quillo run license_keys.qwe$ curl 'localhost:8080/buy'$ curl 'localhost:8080/checks'

Source

license_keys.qwer
//! Signed licence keys for software you sell: who it is for, which features, until when; made with your private key, checked offline with the public one, and impossible to forge.//! version: 1.0.0//!//! [dependencies]//! ed25519-dalek = "2"//! rand = "0.8"//! base64 = "0.22"//! data-encoding = "2"use base64::engine::general_purpose::STANDARD;use base64::Engine;use data_encoding::BASE32_NOPAD;use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};use rand::RngCore;use serde_json::{json, Value};use std::collections::HashMap;fn key32(text: &str, what: &str) -> Result<[u8; 32], String> {    let b = STANDARD.decode(text.trim()).map_err(|_| format!("the {} key is not base64", what))?;    b.try_into().map_err(|_| format!("the {} key is 32 bytes (44 base64 characters)", what))}fn today() -> String {    let days = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64) / 86400;    let z = days + 719468;    let era = z.div_euclid(146097);    let doe = z - era * 146097;    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);    let mp = (5 * doy + 2) / 153;    let d = doy - (153 * mp + 2) / 5 + 1;    let m = if mp < 10 { mp + 3 } else { mp - 9 };    format!("{:04}-{:02}-{:02}", yoe + era * 400 + if m <= 2 { 1 } else { 0 }, m, d)}fn is_date(s: &str) -> bool {    let b = s.as_bytes();    b.len() == 10 && b[4] == b'-' && b[7] == b'-' && s.chars().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())}/// A new signing pair: `{ private, public }`, base64. Keep `private` where keys are made (never in the app you ship); put/// `public` in the app.pub fn license_keypair() -> HashMap<String, String> {    let mut seed = [0u8; 32];    rand::rngs::OsRng.fill_bytes(&mut seed);    let k = SigningKey::from_bytes(&seed);    HashMap::from([("private".into(), STANDARD.encode(k.to_bytes())), ("public".into(), STANDARD.encode(k.verifying_key().to_bytes()))])}/// A licence key for `customer` with `features`, valid until `expires` (a date, `2027-12-31`, included; empty for ever),/// signed with `private_key`. Upper-case letters and digits in groups of five, such as `LIC-MFRGG-...`, about 130 to 200/// characters: paste-able, and readable by license_check alone.pub fn license_make(private_key: &str, customer: &str, features: Vec<String>, expires: &str) -> Result<String, String> {    if !expires.is_empty() && !is_date(expires) {        return Err(format!("`{}` is not a date like 2027-12-31", expires));    }    if customer.len() > 200 || features.len() > 50 {        return Err("a customer of up to 200 characters, and up to 50 features".into());    }    let k = SigningKey::from_bytes(&key32(private_key, "private")?);    let payload = serde_json::to_vec(&json!({ "c": customer, "f": features, "e": expires })).map_err(|e| e.to_string())?;    let mut data = payload.clone();    data.extend_from_slice(&k.sign(&payload).to_bytes());    let code = BASE32_NOPAD.encode(&data);    let groups: Vec<String> = code.as_bytes().chunks(5).map(|c| String::from_utf8_lossy(c).into_owned()).collect();    Ok(format!("LIC-{}", groups.join("-")))}/// What a key says, checked with `public_key`: `{ valid, customer, features, expires, expired, reason }`. `valid` is true/// only for a key made with the matching private key and not past its date; a typo or a forged key gives `valid: false` and a/// `reason`. Spaces, dashes and lower case in the pasted key do not matter.pub fn license_check(key: &str, public_key: &str) -> Result<Value, String> {    let pk = VerifyingKey::from_bytes(&key32(public_key, "public")?).map_err(|_| "not an Ed25519 public key".to_string())?;    let bad = |reason: &str| json!({ "valid": false, "customer": null, "features": [], "expires": null, "expired": false, "reason": reason });    let cleaned: String = key.trim().trim_start_matches("LIC-").trim_start_matches("lic-").chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_uppercase();    if cleaned.len() > 2000 {        return Ok(bad("too long to be a licence key"));    }    let Ok(data) = BASE32_NOPAD.decode(cleaned.as_bytes()) else { return Ok(bad("not a licence key: check for a typo")) };    if data.len() <= 64 {        return Ok(bad("not a licence key: too short"));    }    let (payload, sig) = data.split_at(data.len() - 64);    let Ok(sig) = Signature::from_slice(sig) else { return Ok(bad("not a licence key")) };    if pk.verify_strict(payload, &sig).is_err() {        return Ok(bad("the key was not made by us, or a character is wrong"));    }    let v: Value = serde_json::from_slice(payload).map_err(|e| e.to_string())?;    let expires = v["e"].as_str().unwrap_or("").to_string();    let expired = !expires.is_empty() && today().as_str() > expires.as_str();    Ok(json!({        "valid": !expired,        "customer": v["c"],        "features": v["f"],        "expires": if expires.is_empty() { Value::Null } else { Value::String(expires.clone()) },        "expired": expired,        "reason": if expired { Value::String(format!("expired on {}", expires)) } else { Value::Null },    }))}/// Whether `key` is valid and holds `feature`.pub fn license_has(key: &str, public_key: &str, feature: &str) -> Result<bool, String> {    let c = license_check(key, public_key)?;    Ok(c["valid"] == Value::Bool(true) && c["features"].as_array().is_some_and(|f| f.iter().any(|x| x.as_str() == Some(feature))))}
RSA rsa.qwer · 1.0.0RSA key pairs as PEM, PKCS#1 v1.5 SHA-256 signatures for partner and bank APIs, and OAEP encryption of short secrets.signingencryptionkeysapi9 views · 9 downloadsWebhooks webhooks.qwer · 1.0.0Check that a webhook came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender, with timestamp tolerance; and sign your own.webhooksapipaymentssigning5 views · 10 downloadsBiscuit tokens biscuit.qwer · 1.0.0Biscuit authorization tokens: issued with Datalog facts and checks signed by your key, narrowed by their holder without asking the issuer, and authorized offline against each request.authtokensapisigning7 views · 8 downloadsDKIM, SPF and DMARC email_auth.qwer · 1.0.0Messages signed and checked with DKIM (RSA and Ed25519), SPF records read and evaluated for a sending IP, and DMARC policies applied, with DNS records given as a map so it runs offline.emailsigningdnssecurity6 views · 6 downloadsHPKE hpke.qwer · 1.0.0Hybrid Public Key Encryption (RFC 9180): seal to a public key and open with the private one, in base, PSK, auth and auth-PSK modes, on X25519, P-256, P-384 and P-521 with AES-GCM or ChaCha20-Poly1305, with secrets exported, checked against all 28 of the RFC's test vectors.encryptioncryptographykeys3 views · 6 downloadsOpenPGP openpgp.qwer · 1.0.0OpenPGP through the pure-Rust pgp crate: keys made and read, messages encrypted to keys or a password and decrypted, cleartext and detached signatures made and verified, signed and encrypted together, and what an armored block holds.encryptionsigningpgpemail3 views · 7 downloads
Thread
67Useful? Like it, or add to its thread below.

Thread 0 posts

On Licence keys: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.