Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions crates/lockdocs/src/pro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use std::time::Duration;

use mcp_kit::licence::{self, Licence, LicencePolicy, ProRequired};

/// Marker for the key list below. No token verifies while it is the only entry.
pub const KEY_PLACEHOLDER: &str = "PLACEHOLDER-lockdocs-pro-issuer-public-key-not-issued-yet";
/// The lockdocs-pro issuer public key (base64url, raw Ed25519). Public, not a secret.
pub const ISSUER_PUBLIC_KEY: &str = "3WKM0pn_td25hztpKOVCSFZvE8zs06uXrMWU1p5O4so";

/// Base URL of the in-terminal checkout.
#[allow(dead_code)]
Expand All @@ -18,16 +18,12 @@ pub const CHECKOUT_BASE: &str = "https://buy.sylphx.com";
pub const PRICE: &str = "US$120";

/// The lockdocs Pro licence policy.
///
/// TODO(Services S1): replace `KEY_PLACEHOLDER` with the issued lockdocs-pro
/// Ed25519 public key (base64url). Until then this list contains no valid key,
/// so every token is invalid and nothing Pro unlocks.
pub const POLICY: LicencePolicy<'static> = LicencePolicy {
product: "lockdocs",
tier: "Pro",
require_product: true,
accepted_plans: &["pro"],
public_keys: &[KEY_PLACEHOLDER],
public_keys: &[ISSUER_PUBLIC_KEY],
env_var: "LOCKDOCS_LICENCE_TOKEN",
file_name: "licence",
// Published pricing page (docs/pro.md). The price itself is `docs/pricing.json`.
Expand All @@ -54,12 +50,35 @@ pub fn renewal_note(policy: &LicencePolicy, licence: &Licence) -> Option<String>
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};

fn token(key: &SigningKey, payload: &str) -> String {
let sig = key.sign(payload.as_bytes());
format!("{}.{}", URL_SAFE_NO_PAD.encode(payload), URL_SAFE_NO_PAD.encode(sig.to_bytes()))
}

#[test]
fn placeholder_key_verifies_nothing() {
assert_eq!(POLICY.public_keys, &[KEY_PLACEHOLDER]);
fn issuer_key_is_pinned_and_decodes_to_32_bytes() {
assert_eq!(POLICY.public_keys, &[ISSUER_PUBLIC_KEY]);
assert_eq!(URL_SAFE_NO_PAD.decode(ISSUER_PUBLIC_KEY).unwrap().len(), 32);
for t in ["", "x.y", "e30.AAAA"] {
assert!(POLICY.verify(t).is_err());
}
}

#[test]
fn token_from_a_listed_key_verifies_and_another_key_does_not() {
let listed = SigningKey::from_bytes(&[7; 32]);
let other = SigningKey::from_bytes(&[8; 32]);
let public: &'static str = Box::leak(URL_SAFE_NO_PAD.encode(listed.verifying_key().to_bytes()).into_boxed_str());
let keys: &'static [&'static str] = Box::leak(vec![public].into_boxed_slice());
let policy = LicencePolicy { public_keys: keys, ..POLICY };
let payload = r#"{"plan":"pro","issuedAt":1,"product":"lockdocs"}"#;
assert!(policy.verify(&token(&listed, payload)).is_ok());
assert!(policy.verify(&token(&other, payload)).is_err());
// The pinned production key refuses a token from a key it does not list.
assert!(POLICY.verify(&token(&listed, payload)).is_err());
}
}
2 changes: 1 addition & 1 deletion crates/lockdocs/tests/cli_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ fn pro_commands_exit_3_and_free_commands_do_not() {
let o = run(&["licence", "status"], home.path());
assert_eq!(o.status.code(), Some(0));
assert!(String::from_utf8_lossy(&o.stdout).contains("inactive"));
// The placeholder key refuses every token, so activate fails (exit 1), never 3.
// The pinned issuer key refuses an unsigned token, so activate fails (exit 1), never 3.
let o = run(&["licence", "activate", "e30.AAAA"], home.path());
assert_eq!(o.status.code(), Some(1));
let o = run(&["version"], home.path());
Expand Down
Loading