Time Limited Access - SmithSoft-Studios/JAZMIN GitHub Wiki
Time-Limited Access
Give each access key its own access period, such as 2 hours for a contractor, 2 weeks for a partner and 5 years for an archive. Choose per key how strictly the period is enforced:
| Mode | How it works | Works offline | Strength |
|---|---|---|---|
| offline (default) | The expiry is sealed and signed in the key's slot of the file. The library refuses to open the file after it, and also refuses when the clock looks wound back | Yes | Stops honest users and casual clock changes |
| online | Opening also needs an unlock token from your key service, which issues tokens only before the expiry | No: one request per open | Nobody can open the file after the expiry |
| online + 2FA | Your key service asks for an authenticator code before issuing a token | No | A stolen access key alone isn't enough |
Three keys, three periods
import { JazminAccessExpiredError, JazminKey, JazminUnlockRequiredError, issueUnlockToken, open, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate();
const contractor = owner.createAccessKey();
const partner = owner.createAccessKey();
const archive = owner.createAccessKey();
write('report.jzm', [{ region: 'ZA', revenue: 1200 }, { region: 'NA', revenue: 800 }], {
key: owner,
now: Date.now() - 3 * 3600_000, // as if written 3 hours ago, so the 2-hour key has already lapsed
access: {
grants: [
{ key: contractor, expiresIn: '2h', label: 'contractor' },
{ key: partner, expires: '2099-01-01T00:00:00Z', mode: 'online', label: 'partner' },
{ key: archive, expiresIn: '5y', label: 'archive' },
],
},
});
// Readers keep a last-seen record per key to catch a clock set back (default: the user's app-data folder)
const accessState = { dir: 'access-state' };
try {
open('report.jzm', { key: contractor.export(), accessState });
} catch (e) {
if (e instanceof JazminAccessExpiredError) console.log('contractor:', e.message.replace(/\d{4}-[\d-]+T[\d:.]+Z/, '<an hour ago>'));
}
let reader;
try {
reader = open('report.jzm', { key: partner.export(), accessState });
} catch (e) {
if (!(e instanceof JazminUnlockRequiredError)) throw e;
console.log('partner:', e.message);
// Your key service (which holds the owner key) receives e.fileId and e.keyId, checks the caller, and issues a token
const token = issueUnlockToken('report.jzm', owner, e.keyId);
reader = open('report.jzm', { key: partner.export(), unlockToken: token, accessState });
}
console.log('partner:', reader.rowCount, 'rows; online:', reader.access.online, '; expires', reader.access.expires);
const a = open('report.jzm', { key: archive.export(), accessState });
const years = (new Date(a.access.expires) - Date.now()) / (365.25 * 86_400_000);
console.log('archive:', a.rowCount, 'rows; expires in about', Math.round(years), 'years');
contractor: Access for this key expired at <an hour ago>
partner: This key needs an unlock token from the file owner's key service
partner: 2 rows; online: true ; expires 2099-01-01T00:00:00.000Z
archive: 2 rows; expires in about 5 years
| Grant option | JavaScript | .NET |
|---|---|---|
| Relative period, from when the file is written | expiresIn: '2h', '14d', '5y', or milliseconds |
ExpiresIn = TimeSpan.FromHours(2) |
| Absolute end | expires: '2031-10-01T00:00:00Z' (or a Date, or epoch ms) |
Expires = DateTimeOffset... |
| Online | mode: 'online' |
Mode = JazminGrantMode.Online |
In .NET
using Jazmin;
var owner = JazminKey.Generate();
var partner = owner.CreateAccessKey();
var columns = new[] { new JazminColumn("region", JazminType.String), new JazminColumn("revenue", JazminType.Int) };
var options = new JazminWriteOptions
{
Key = owner,
Access = new JazminAccessOptions
{
Grants = [new JazminGrant(partner) { ExpiresIn = TimeSpan.FromDays(14), Mode = JazminGrantMode.Online, Label = "partner" }],
},
};
using (var writer = JazminWriter.Create("report.jzm", columns, options))
writer.WriteValues("ZA", 1200L);
var read = new JazminReadOptions { AccessKey = partner, AccessState = new JazminDirectoryAccessStateStore("access-state") };
JazminReader reader;
try
{
reader = JazminReader.Open("report.jzm", read);
}
catch (JazminUnlockRequiredException e)
{
Console.WriteLine(e.Message);
var token = JazminFile.IssueUnlockToken("report.jzm", owner, e.KeyId); // in your key service
read.UnlockToken = token;
reader = JazminReader.Open("report.jzm", read);
}
using (reader)
Console.WriteLine($"{reader.RowCount} row; online {reader.Access!.Online}; about {(reader.Access.Expires!.Value - DateTimeOffset.UtcNow).TotalDays:F0} days left");
Console.WriteLine($"{JazminFile.ListUnlockTokens("report.jzm", owner).Count} online grant(s) for the key service");
This key needs an unlock token from the file owner's key service
1 row; online True; about 14 days left
1 online grant(s) for the key service
The key service
For online grants you run a small service that holds the owner key (or tokens it stored earlier) and answers "may this key open this file now?":
- The client's
open()fails withJazminUnlockRequiredError(JazminUnlockRequiredException), carrying the file'sfileIdand the key'skeyId. Both are safe to send; neither is a secret. - The client calls your endpoint with them, authenticated however your app authenticates users.
- The service checks the caller (and, optionally, a 2FA code), then calls
issueUnlockToken(path, owner, keyId). After the expiry it throwsJazminAccessExpiredErrorinstead. - The client opens the file with
{ key, unlockToken }.
inspect(path).fileId reads the file id without any key, and listUnlockTokens(path, owner) returns every online
grant's token at once, for services that store tokens rather than the owner key.
The repository's dotnet/samples/Jazmin.KeyService
is a complete key service with 2FA (TOTP authenticator codes): enrolment by QR code, each code accepted once, and a
lockout after repeated wrong codes. It can run without the owner key: give it a tokens file listed with
listUnlockTokens on a trusted machine (Jazmin__TokensFile), so breaking into it opens nothing.
To try it in a browser, build the from-disk sample (node js/examples/from-disk/make.mjs) and open
two-factor.html: set up an authenticator app by QR code, see the access key alone refused, then open the file with a
code. The service checks the code and gives the token in about 15 ms; the file then opens in about 14 ms.
Key services for locked files and masked columns (1.7.0)
The same kind of service can hand out the tokens of locked files
and masked columns whose lock is online: true. Such a lock never expires by itself: it adds a second
check, such as 2FA, on top of the access key.
- Name the service in the file:
package.keyService(.NETJazminPackage.KeyService) for the file's online locks, orkeyServiceon one lock (.NETJazminLockOptions.KeyService), so one owner can lock attachments for several providers, each signing its own people in and holding only its own lock's token. - The service issues
issueFileUnlockToken(path, owner, lockId)(.NETJazminFile.IssueFileUnlockToken), ajzf1-token, after its own checks. A service that stores tokens instead of the owner key lists them once withlistFileUnlockTokens(path, owner)(.NETJazminFile.ListFileUnlockTokens). - Readers say where each token comes from:
reader.locks[i].keyService(.NETJazminLockInfo.KeyService).
import { JazminKey, listFileUnlockTokens, open, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate();
const member = owner.createAccessKey();
write('claim.jzm', [{ id: 1 }], {
key: owner, columns: [{ name: 'id', type: 'int' }], access: { grants: [{ key: member }] },
files: [{ path: 'contract.pdf', content: 'The contract', lock: 'contracts' }, { path: 'scan.png', content: 'The scan', lock: 'medical' }],
locks: { contracts: { online: true }, medical: { online: true, keyService: 'https://keys.medicalaid.example' } },
package: { keyService: 'https://keys.owner.example' },
});
const reader = open('claim.jzm', { key: member });
for (const l of reader.locks) console.log(`${l.id}: ask ${l.keyService}`);
reader.close();
// On a trusted machine: the tokens for a service that runs without the owner key (never print or log real ones)
const tokens = listFileUnlockTokens('claim.jzm', owner);
console.log(tokens.map((t) => `${t.lockId} ${t.token.slice(0, 5)}...`));
contracts: ask https://keys.owner.example/
medical: ask https://keys.medicalaid.example/
[ 'contracts jzf1-...', 'medical jzf1-...' ]
In the web viewer, Unlock… on a lock whose file or lock names a key service offers Sign in at …: the service
opens in a window of its own, the person signs in and passes its 2FA there, and the service posts the token back. The
viewer accepts it only from that service's origin, and only for the file and lock it asked about, so nothing is copied
by hand. The sample service (dotnet/samples/Jazmin.KeyService)
has the sign-in page (GET /jazmin/unlock), POST /unlock-lock and POST /admin/locks (which lock a person may
open), and reads lock tokens from its tokens file (locks, from ListFileUnlockTokens) or with the owner key. A key
service must be https, except on a loopback host (localhost, 127.0.0.1, [::1]) for development.
How a clock set back is caught (offline keys)
- The file's signed date. If the clock reads earlier than when the file was written, the file is refused. The date is covered by the owner's signature, so it can't be edited.
- A last-seen record per user, file and key, kept in
%LOCALAPPDATA%\Jazmin\access-state(Windows) or~/.jazmin/access-state. If the clock reads earlier than the last access, the file is refused with "Clock rollback detected". The record is protected with the access key, so editing it is detected. JavaScript and .NET share it.- Move it with
accessState: { dir }(.NETAccessState = new JazminDirectoryAccessStateStore(dir)), or keep it in your own database with a custom store ({ read(name), write(name, text) }, .NETIJazminAccessStateStore). - Turn it off with
accessState: false(.NETCheckClockRollback = false). Your application decides this, not the end user.
- Move it with
- 5 minutes of tolerance in each check allows for ordinary clock differences.
The record isn't kept in the file because a user could replace the file with an older copy, readers would need write access to a shared signed file, and read-only copies couldn't be updated.
What it does and doesn't guarantee
- Expired keys are cut off from new versions. A full
update()orcompact()removes expired grants and re-locks the file with fresh secrets. Anappend()removes their key slots but keeps the secrets, so compact when you need the complete cut-off. - Offline expiry relies on the reader. Someone who deletes their last-seen record and winds the clock back, or runs modified software, can still open a copy they already have. Use online mode when that matters.
- Online expiry stops keys that have no token yet. A token obtained before the expiry still opens the copies it was issued for, until the owner rewrites the file. For a complete cut-off, compact or update after the expiry, or revoke the key.
- The owner key never expires.
- The reader's
nowoption is deprecated and will be removed in 2.0: setting it back lets an expired key open a file. Don't use it in applications.