# A license client with no Keychain: a 0600 file, weekly revalidation and a soft wall

> A small macOS license client in Swift: a 0600 JSON file, a weekly silent check that offline never fails, and a trial wall that never touches user data.

Canonical: https://www.notchstar.com/blog/license-client-without-keychain-swift/
Last updated: 2026-11-04

*How NotchStar stores a license key on a Mac, when it asks the server again, and what happens when the trial ends*

Published 2026-11-04.

## TL;DR

- NotchStar keeps its trial start and license in one small JSON file in Application Support, mode `0600`, and does not use the Keychain. The reason is written in the source: an ad hoc signed app can get a Keychain prompt after every update.
- The license is checked with the server at most once a week, silently. Only an explicit `valid: false` can revoke it. Being offline, a timeout or a server error changes nothing.
- When the trial ends, only the tool area is replaced by a prompt. Notes, clipboard and settings are not touched. The price of this design is that the file can be edited by its owner, and the post says so.

## The three rules

The whole licensing layer is 382 lines of Swift in `Sources/NotchStar/Licensing/` (counted with `wc -l` on 4 November 2026). Five files: a store, a manager, a Dodo Payments client, a trial policy and the views. Most of the design is already visible in the doc comment on `LicenseManager`:

```swift
/// Owns the trial and the licence. The rules, in order of importance:
/// 1. Never delete or hide the user's data. A lapsed trial only replaces the tool area with a prompt.
/// 2. Being offline never revokes a licence. Only an explicit `valid: false` from Dodo can.
/// 3. Check in at most once a week, silently.
```

Those three lines drive every decision below. A small app sold once has little to protect, and a lot to lose from a false lockout: a person on a plane, behind a company firewall or in a tunnel who opens the notch and finds a paywall. The app asks the question "is this still valid" rarely, and treats "I could not find out" as "yes".

The client talks to Dodo Payments, the merchant of record, through its public license endpoints. Those need no API key, which means no secret ships inside the app. A test enforces that: `testActivateSendsTheRightRequestAndNoCredentials` asserts the request has no `Authorization` header. The details of that API are in a separate post on Dodo's gotchas. This post is about what the app does with the answers.

## What is stored

Two small value types hold everything:

```swift
struct LicenseRecord: Codable, Equatable {
    var key: String
    var instanceID: String
    var email: String?
    var activatedAt: Date
    var lastValidatedAt: Date
    var revoked = false
}

struct LicensePersisted: Codable, Equatable {
    var trialStart: Date?
    var license: LicenseRecord?
}
```

The trial start and the license live in the same value. That is deliberate and it has a consequence that comes up again in the limits section. `instanceID` is what Dodo returns when a key is activated on a Mac. It identifies that one activation, so a later check or a deactivation can name it. `revoked` is set only when Dodo says the key is no longer valid, for example after a refund.

The store is a protocol with two methods, `load()` and `save(_:)`. There are two implementations: `MemoryLicenseStore`, used by tests, demo mode and the UI tests, and `FileLicenseStore`. Because the manager only sees the protocol, every rule in the next sections is tested against memory with a clock the test controls.

## The wrong turn: the Keychain

The obvious place for a license key on macOS is the Keychain. It was ruled out, and the reason is the comment above `FileLicenseStore`:

```swift
/// A small owner-only JSON file in Application Support. Deliberately not the Keychain: an ad-hoc-signed app
/// gets a Keychain prompt after every update because its signature changes. Revisit once the app is
/// Developer ID signed.
```

At release 1.0.0 on 7 October 2026 the app was ad hoc signed (`blog/FACTS.md`, section A). An ad hoc signature changes with every build, and the Keychain decides whether an app may read an item by the app's code signature. The comment records the expected outcome: a password prompt after each update, for an app whose whole pitch is that it stays out of the way. That behaviour was not reproduced for this post, so treat it as the source's stated reason and not as a measurement.

The comment ends with a promise to revisit. Since 1.0.3 on 9 October 2026 the app has been signed with a Developer ID and notarized, so the original reason may no longer apply. The comment is still there, and the file store is still what ships. That is a decision waiting to be made again, not a settled one.

## A 0600 file

Here is the save path:

```swift
func save(_ value: LicensePersisted) {
    lock.lock(); defer { lock.unlock() }
    let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
    encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
    guard let data = try? encoder.encode(value) else { return }
    try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
    try? data.write(to: url, options: .atomic)
    try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
```

Three details matter. The write is atomic, so a crash halfway never leaves a half written file. A lock serializes load and save, since both can be called from the main actor and from the weekly check. And the permissions are set after the write, to `0600`: the owner can read and write, nobody else can. On a shared Mac, another user account cannot read the key. `FileLicenseStoreTests.testRoundTripAndOwnerOnlyPermissions` asserts the mode is exactly `0o600`.

Loading is the other half:

```swift
guard let data = try? Data(contentsOf: url) else { return LicensePersisted() }
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode(LicensePersisted.self, from: data)) ?? LicensePersisted()
```

A missing or corrupt file reads as an empty value. The comment next to it says why: a corrupt file must never crash the app. `testCorruptFileFallsBackToEmptyInsteadOfCrashing` writes the text `not json` and checks that no license comes back.

### Is the order safe

Setting the permissions after the write raises a fair question: is there a moment when the file exists with looser permissions? A short Swift script answers it. It writes a file atomically, sets `0600`, then writes again over it. Run on 4 November 2026 on macOS 27.0.1 with Swift 6.4 and a umask of `022`:

```text
after atomic write: 644
after setAttributes: 600
after second atomic write over a 0600 file: 600
```

So on the first save, between the write and the permission change, the file is `0644`. On later saves Foundation keeps the existing mode when it replaces the file, so the window does not reopen. The application support folder itself is `755` on this Mac. The first save happens at first launch, when the trial starts and the file holds only a date, so no key is exposed in that window. The key is written later, on activation, over a file that is already `0600`. This is reasoning from the code plus the measurement above, not a test in the repo. Creating the file with the right mode in the first place (for example by writing through a file descriptor opened with `0600`) would remove the question, and it is a small change.

On the author's Mac the real file is `600`, checked with `stat -f '%Lp' ~/Library/Application\ Support/NotchStar/license.json` on 4 November 2026. That command prints the mode only, never the contents.

## The trial is a pure function

The trial is counted in whole days from the first launch:

```swift
enum TrialPolicy {
    static let days = 7
    static func elapsedDays(start: Date, now: Date) -> Int {
        max(0, Int(now.timeIntervalSince(start) / 86_400))   // a clock set backwards counts as day 0
    }
    static func daysLeft(start: Date, now: Date) -> Int { max(0, days - elapsedDays(start: start, now: now)) }
    static func hasEnded(start: Date, now: Date) -> Bool { elapsedDays(start: start, now: now) >= days }
}
```

There is no state in it, only dates in and numbers out, which is why four short tests cover its edges. One is worth a look: `testAClockSetBackwardsDoesNotExtendBeyondTheFullTrial` sets the clock ten days before the start and expects 7 days left, not 17. The `max(0, ...)` is what does it. The trial can never be longer than seven days by moving the clock back, though it can still be stretched by moving the clock back before the day 7 check and keeping it there, which is the nature of any local clock.

The manager turns a persisted value into a state:

```swift
private static func compute(_ p: LicensePersisted, now: Date) -> LicenseState {
    if let l = p.license { return l.revoked ? .revoked : .licensed(email: l.email) }
    let start = p.trialStart ?? now
    return TrialPolicy.hasEnded(start: start, now: now) ? .trialEnded : .trial(daysLeft: TrialPolicy.daysLeft(start: start, now: now))
}
```

A license always wins over the trial. There are four states: `trial(daysLeft:)`, `trialEnded`, `licensed(email:)` and `revoked`. Only the first and third count as active.

## Weekly revalidation

The check runs once per launch from the app delegate, in a task that nothing waits for:

```swift
Task { await LicenseManager.shared.revalidateIfDue() }   // silent, at most weekly, never blocks
```

And the method itself:

```swift
func revalidateIfDue() async {
    var p = store.load()
    guard var l = p.license, !l.revoked,
          now().timeIntervalSince(l.lastValidatedAt) >= Self.revalidateInterval else { return }
    guard let valid = try? await client.validate(key: l.key, instanceID: l.instanceID) else { return }
    if valid { l.lastValidatedAt = now() } else { l.revoked = true }
    p.license = l; store.save(p); refresh()
}
```

Read it from the top. If there is no license, or it is already revoked, or fewer than seven days have passed since the last good check, it returns without a network call. If the call throws for any reason, the `try?` turns that into `nil` and the `guard` returns. Only a successful answer changes anything, and only `false` revokes.

The client supports that rule from its side. Its `validate` returns `true` only if Dodo says so, and "anything unclear throws, so a hiccup is never mistaken for a revoked licence". A 200 with a body it cannot decode throws. A 500 throws. A timeout, set to 15 seconds in the request, becomes `LicenseError.network`, which also throws. The one non 200 answer that means "no" is a 404, and that returns `false`.

The tests read like the rules:

| Test | What it proves |
|---|---|
| `testBeingOfflineNeverRevokesALicence` | 90 days with the network down: still `licensed` |
| `testRevalidationIsRateLimitedToOncePerWeek` | 2 days later: zero requests. 6 more days: exactly one |
| `testRefundedOrRevokedKeyStopsWorkingAfterRevalidation` | 8 days later, `valid: false` gives `revoked` |
| `testActivationPersistsAndSurvivesRelaunch` | 400 days later, a new manager is still `licensed` with no network |
| `testDeactivateStillClearsLocallyIfOffline` | removing a license from this Mac never needs the server |

The full licensing test file has 22 test functions (`grep -c "func test" Tests/NotchStarTests/LicensingTests.swift`, 4 November 2026): 4 for the trial, 6 for the Dodo client, 9 for the manager, 2 for the file store and 1 live test against Dodo's test API that is skipped unless a test key is supplied.

## The soft wall

When the state is not active, the root view of the notch swaps the tool area for a prompt:

```swift
ZStack {
    if !model.license.state.isActive {
        LicenseWall(license: model.license).transition(.opacity)
    } else if let tool = model.selectedTool {
        tool.makeView().id(tool.id).transition(.opacity)
    }
}
```

The tab bar, the pin and Settings stay usable. The wall says either "Your 7 day trial has ended" or, for a revoked key, "This license is no longer active", with a note that it may have been refunded and an address to write to. It offers two buttons: buy, and "I have a key", which opens the license tab of Settings. The text for the trial case says the notes, clipboard and settings are untouched, and that is literally true: nothing in the licensing code deletes or hides data, and `testTrialEndsButNothingIsDeleted` checks the state change and nothing else.

"Soft" here means two things. The wall hides the tools instead of deleting anything, and it can be undone by activating a key at any time. A licensed user who is refunded sees the same wall and their data is still there.

Deactivation follows the same spirit. It frees the Mac's slot on the server, but it always removes the license locally, even if the request fails:

```swift
if let l = p.license { try? await client.deactivate(key: l.key, instanceID: l.instanceID) }
p.license = nil; store.save(p); refresh()
```

A person must always be able to take a license off a Mac, whether or not the server answers. The app's own error text for a fourth activation tells the user to deactivate one of their three Macs from its Settings.

## What is still bad

- **The file can be edited.** It is plain JSON with no signature. `compute` trusts a `license` record and does not ask the server on load, so a hand written record with any key and instance ID reads as licensed until the next weekly check, and the check only revokes when the server says `valid: false` for that pair. `0600` keeps other users out. It does nothing against the person who owns the account, and nothing against a process that runs as that person.
- **The key is stored in plain text.** The Keychain would encrypt it at rest. The file does not.
- **The trial can be reset.** The trial start sits in the same file. Deleting it makes the next launch start a fresh seven days. A hard to reset trial would need something outside the user's home folder, and that conflicts with rule 1 and with a product that promises no account.
- **A 404 revokes.** In `validate`, a 404 returns `false`, which the manager treats as revoked. If Dodo ever answers 404 for a reason other than a missing license, a valid key would be revoked after the next due check. Today the live API answers a wrong key with 200 and `{"valid":false}` (checked on 4 November 2026), so the 404 branch may never fire. It is still a branch that trusts a status code.
- **First save is briefly `0644`.** Described above. It holds only a date at that point.
- **Not exercised end to end.** The 3 device limit and the weekly check were tested against Dodo's test API and a mock. The live purchase flow has not been run end to end by an agent (`blog/FACTS.md`, section C).
- **The Keychain question is open.** The code comment says to revisit it once the app is Developer ID signed. It has been signed since 1.0.3 and the file store remains.

## Try it yourself

You can build the same pattern without this app. Four steps:

1. Put everything the app remembers in one `Codable` value and hide storage behind a two method protocol, `load()` and `save(_:)`. Ship a memory implementation next to the file one.
2. Inject the clock. Pass `now: () -> Date` into the manager and never call `Date()` inside the rules. Every test in the table above depends on this.
3. Write the file atomically, then set `0600`. A minimal version:

```swift
import Foundation

func saveOwnerOnly(_ data: Data, to url: URL) throws {
    try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
    try data.write(to: url, options: .atomic)
    try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
```

4. Make the server check return `Bool` or throw, never a third state, and let only `false` change anything. Then write the offline test first: set the network handler to throw `URLError(.notConnectedToInternet)`, move the clock 90 days and assert the state did not change.

To check a mode from Terminal, print it and compare it to `600`:

```bash
stat -f '%Lp %N' ~/Library/Application\ Support/YourApp/license.json
```

## Cheat sheet

| Decision | What NotchStar does | Where |
|---|---|---|
| Where the key lives | JSON file, mode `0600`, Application Support | `LicenseStore.swift` |
| Why not the Keychain | Prompt after each update on an ad hoc signed app (source comment) | `LicenseStore.swift` |
| Trial | 7 whole days from first launch, clock set back counts as day 0 | `TrialPolicy.swift` |
| Server check | At most every 7 days, once per launch, silent | `LicenseManager.swift` |
| What revokes | An explicit `valid: false` or a 404, nothing else | `DodoLicenseClient.swift` |
| Offline | Never changes the state | `testBeingOfflineNeverRevokesALicence` |
| Request timeout | 15 seconds | `DodoLicenseClient.post` |
| After the trial | Tool area replaced by a prompt, data untouched | `LicenseViews.swift` |
| Corrupt file | Reads as empty, never crashes | `FileLicenseStoreTests` |

- [ ] State the rules before the code, in a doc comment
- [ ] Hide storage behind a protocol and inject the clock
- [ ] Treat "could not find out" as "still valid"
- [ ] Rate limit the check and make it silent
- [ ] Never delete or hide user data when a license lapses
- [ ] Always let a user remove a license locally
- [ ] Write down what the file does not protect against

If you want to see the result in the notch, NotchStar is at [notchstar.com](https://www.notchstar.com/). The licensing layer is small enough to read in one sitting.

## More posts

- [Finding new screenshots, and the macOS 27 custom folder bug](https://www.notchstar.com/blog/finding-new-screenshots-macos-27-custom-folder/)
- [Dodo Payments license API gotchas: 201 not 200, and the live host](https://www.notchstar.com/blog/dodo-payments-license-api-gotchas/)
