Skip to content

Commit 18898a8

Browse files
committed
feat!: enforce mandatory token expiration with capped lifetimes
Changes: - Add time constants (ONE_MINUTE_MS, ONE_HOUR_MS, FOUR_WEEKS_MS, DEFAULT_MAX_LIFETIME_MS) - Add `expiresIn()` method to builder for ergonomic expiration setting - Add `maxLifetime()` method to customize maximum token lifetime - Add validation in `sign()` to require expiration and enforce lifetime caps - Fix time unit bug: convert milliseconds to seconds in payload (exp, nbf) - Cap chained token lifetimes by parent's remaining validity - Update NilauthClient to set expiration on internal tokens - Update documentation and all tests to use new expiration API Security improvements: - Prevents tokens from being created without expiration dates - Enforces maximum lifetime of 4 weeks by default - Child tokens cannot outlive their parents - Provides clear error messages when lifetime constraints are violated BREAKING CHANGE: All tokens must now specify an expiration via `expiresAt()` or `expiresIn()`. Tokens without expiration will be rejected during the build process.
1 parent 978b616 commit 18898a8

16 files changed

+334
-8
lines changed

DOCUMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const rootDelegation = await Builder.delegation()
5656
["==", ".command", "/nil/db/collections"], // Command must be an attenuation
5757
["!=", ".args.collection", "secrets"]
5858
])
59-
.expiresAt(Date.now() + 3600 * 1000) // Expires in 1 hour
59+
.expiresIn(3600 * 1000) // Expires in 1 hour
6060
.sign(rootSigner);
6161

6262
// Step 4: Build an invocation token from the delegation

biome.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1+
export const ONE_MINUTE_MS = 60 * 1000;
2+
export const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
3+
export const FOUR_WEEKS_MS = 4 * 7 * 24 * ONE_HOUR_MS;
4+
export const DEFAULT_LIFETIME_MS = ONE_HOUR_MS;
5+
export const DEFAULT_MAX_LIFETIME_MS = FOUR_WEEKS_MS;
16
export const DEFAULT_NONCE_LENGTH = 16;

src/nuc/builder.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { bytesToHex, randomBytes } from "@noble/hashes/utils.js";
2-
import { DEFAULT_NONCE_LENGTH } from "#/constants";
2+
import { DEFAULT_MAX_LIFETIME_MS, DEFAULT_NONCE_LENGTH } from "#/constants";
33
import type { Did } from "#/core/did/types";
44
import { base64UrlEncode } from "#/core/encoding";
55
import type { Signer } from "#/core/signer";
@@ -23,6 +23,7 @@ abstract class AbstractBuilder {
2323
protected _meta?: Record<string, unknown>;
2424
protected _nonce?: string;
2525
protected _proof?: Envelope;
26+
protected _maxLifetimeMs: number = DEFAULT_MAX_LIFETIME_MS;
2627

2728
protected abstract _getPayloadData(issuer: Did): Payload;
2829

@@ -83,6 +84,21 @@ abstract class AbstractBuilder {
8384
return this;
8485
}
8586

87+
/**
88+
* Specifies the token's expiration as a duration from now.
89+
*
90+
* @param ms - The number of milliseconds from now when the token should expire.
91+
* @returns This builder instance for method chaining.
92+
* @example
93+
* ```typescript
94+
* builder.expiresIn(3600 * 1000); // Expires in 1 hour
95+
* ```
96+
*/
97+
public expiresIn(ms: number): this {
98+
this._expiresAt = Date.now() + ms;
99+
return this;
100+
}
101+
86102
/**
87103
* Specifies the earliest time the token becomes valid.
88104
*
@@ -141,6 +157,26 @@ abstract class AbstractBuilder {
141157
return this;
142158
}
143159

160+
/**
161+
* Sets the maximum lifetime for the token being built.
162+
*
163+
* This value cannot exceed the default maximum lifetime or the remaining
164+
* lifetime of a parent proof token.
165+
*
166+
* @param ms - The maximum lifetime in milliseconds.
167+
* @returns This builder instance for method chaining.
168+
* @throws {Error} If the provided lifetime exceeds the allowed maximum.
169+
*/
170+
public maxLifetime(ms: number): this {
171+
if (ms > this._maxLifetimeMs) {
172+
throw new Error(
173+
`Custom max lifetime of ${ms}ms exceeds the allowed maximum of ${this._maxLifetimeMs}ms.`,
174+
);
175+
}
176+
this._maxLifetimeMs = ms;
177+
return this;
178+
}
179+
144180
/**
145181
* Links this token to a previous token in a delegation chain.
146182
*
@@ -197,6 +233,24 @@ abstract class AbstractBuilder {
197233
* ```
198234
*/
199235
public async sign(signer: Signer): Promise<Envelope> {
236+
// Validate expiration properties before signing.
237+
if (!this._expiresAt) {
238+
throw new Error(
239+
"Expiration is a required field. Use `expiresAt(timestamp)` or `expiresIn(duration)`.",
240+
);
241+
}
242+
if (this._expiresAt <= Date.now()) {
243+
throw new Error("Expiration date must be in the future.");
244+
}
245+
const maxExpiry = Date.now() + this._maxLifetimeMs;
246+
if (this._expiresAt > maxExpiry) {
247+
const asConfigured = new Date(this._expiresAt).toISOString();
248+
const maxAllowed = new Date(maxExpiry).toISOString();
249+
throw new Error(
250+
`Expiration of ${asConfigured} exceeds the maximum lifetime. Max expiry is ${maxAllowed}.`,
251+
);
252+
}
253+
200254
// The issuer is now authoritatively derived from the signer.
201255
const issuer = this._issuer ?? (await signer.getDid());
202256
const payloadData = this._getPayloadData(issuer);
@@ -330,8 +384,8 @@ export class DelegationBuilder extends AbstractBuilder {
330384
sub: this._subject,
331385
cmd: this._command,
332386
pol: this._policy,
333-
nbf: this._notBefore,
334-
exp: this._expiresAt,
387+
nbf: this._notBefore ? Math.floor(this._notBefore / 1000) : undefined,
388+
exp: this._expiresAt ? Math.floor(this._expiresAt / 1000) : undefined,
335389
meta: this._meta,
336390
nonce: this._nonce || bytesToHex(randomBytes(DEFAULT_NONCE_LENGTH)),
337391
prf: this._proof
@@ -415,8 +469,8 @@ export class InvocationBuilder extends AbstractBuilder {
415469
sub: this._subject,
416470
cmd: this._command,
417471
args: this._args,
418-
nbf: this._notBefore,
419-
exp: this._expiresAt,
472+
nbf: this._notBefore ? Math.floor(this._notBefore / 1000) : undefined,
473+
exp: this._expiresAt ? Math.floor(this._expiresAt / 1000) : undefined,
420474
meta: this._meta,
421475
nonce: this._nonce || bytesToHex(randomBytes(DEFAULT_NONCE_LENGTH)),
422476
prf: this._proof
@@ -536,6 +590,15 @@ export const Builder = {
536590
builder.command(proofPayload.cmd);
537591
builder.proof(proof);
538592

593+
// Cap the new token's lifetime by its parent's remaining validity.
594+
if (proofPayload.exp) {
595+
const remainingLifetimeMs = proofPayload.exp * 1000 - Date.now();
596+
if (remainingLifetimeMs <= 0) {
597+
throw new Error("Cannot create a chained token from an expired proof.");
598+
}
599+
builder.maxLifetime(remainingLifetimeMs);
600+
}
601+
539602
return builder;
540603
},
541604

@@ -577,6 +640,15 @@ export const Builder = {
577640
builder.command(proofPayload.cmd);
578641
builder.proof(proof);
579642

643+
// Cap the new token's lifetime by its parent's remaining validity.
644+
if (proofPayload.exp) {
645+
const remainingLifetimeMs = proofPayload.exp * 1000 - Date.now();
646+
if (remainingLifetimeMs <= 0) {
647+
throw new Error("Cannot create a chained token from an expired proof.");
648+
}
649+
builder.maxLifetime(remainingLifetimeMs);
650+
}
651+
580652
return builder;
581653
},
582654

src/services/nilauth/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sha256 } from "@noble/hashes/sha2.js";
22
import { bytesToHex, randomBytes } from "@noble/hashes/utils.js";
33
import ky, { HTTPError, type Options } from "ky";
44
import { z } from "zod";
5+
import { ONE_MINUTE_MS } from "#/constants";
56
import { Did } from "#/core/did/did";
67
import {
78
NilauthErrorResponse,
@@ -161,6 +162,7 @@ export class NilauthClient {
161162
.subject(subject)
162163
.audience(this.nilauthDid)
163164
.command(command)
165+
.expiresIn(ONE_MINUTE_MS)
164166
.signAndSerialize(signer);
165167
}
166168

@@ -415,14 +417,19 @@ export class NilauthClient {
415417

416418
const issuer = await signer.getDid();
417419

420+
// Calculate auth token's remaining lifetime
421+
const authTokenExp = authToken.nuc.payload.exp;
422+
const remainingLifetimeMs = (authTokenExp as number) * 1000 - Date.now();
423+
418424
const revokeTokenEnvelope = await Builder.invocationFrom(authToken)
419425
.arguments({
420426
token: Codec.serializeBase64Url(tokenToRevoke),
421427
})
422428
.command(REVOKE_COMMAND)
423429
.audience(this.nilauthDid)
424430
.issuer(issuer)
425-
// Subject is inherited from authToken
431+
// Use most of parent's remaining lifetime (builder will cap if needed)
432+
.expiresIn(Math.min(ONE_MINUTE_MS, remainingLifetimeMs * 0.9))
426433
.sign(signer);
427434

428435
const revokeTokenString = Codec.serializeBase64Url(revokeTokenEnvelope);

tests/integration/heterogeneous-chain.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Wallet } from "ethers";
22
import { describe, it } from "vitest";
3+
import { ONE_HOUR_MS } from "#/constants";
34
import * as ethr from "#/core/did/ethr";
45
import { Signer } from "#/core/signer";
56
import { Builder } from "#/nuc/builder";
@@ -33,6 +34,7 @@ describe("heterogeneous nuc chain", () => {
3334
.audience(userDid)
3435
.subject(userDid)
3536
.command("/nil/db/data")
37+
.expiresIn(ONE_HOUR_MS)
3638
.sign(rootSigner);
3739

3840
// 2. User (did:ethr) delegates to LegacySvc (did:nil)
@@ -41,12 +43,14 @@ describe("heterogeneous nuc chain", () => {
4143
)
4244
.audience(legacySvcDid)
4345
.command("/nil/db/data/find")
46+
.expiresIn(ONE_HOUR_MS / 2)
4447
.sign(userSigner);
4548

4649
// 3. LegacySvc (did:nil) invokes the command for the FinalSvc
4750
const invocation = await Builder.invocationFrom(userToLegacySvcDelegation)
4851
.audience(finalSvcDid)
4952
.arguments({ id: 123 })
53+
.expiresIn(ONE_HOUR_MS / 4)
5054
.sign(legacySvcSigner);
5155

5256
// Phase 3 - Validation

tests/integration/nilauth.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { bytesToHex } from "@noble/hashes/utils.js";
22
import { beforeAll, describe, expect, it } from "vitest";
3+
import { ONE_HOUR_MS } from "#/constants";
34
import { Did } from "#/core/did/did";
45
import { Signer } from "#/core/signer";
56
import { Builder } from "#/nuc/builder";
@@ -88,15 +89,22 @@ describe("nilauth client", () => {
8889
// 2. Delegate root token to a user.
8990
const userSigner = Signer.generate();
9091
const userDid = await userSigner.getDid();
92+
93+
// Calculate parent's remaining lifetime and use a fraction of it
94+
const parentExp = rootToken.nuc.payload.exp;
95+
const remainingLifetimeMs = (parentExp as number) * 1000 - Date.now();
96+
9197
const userDelegation = await Builder.delegationFrom(rootToken)
9298
.audience(userDid)
9399
.subject(userDid)
94100
.command("/some/specific/capability")
101+
.expiresIn(remainingLifetimeMs * 0.8)
95102
.sign(signer);
96103

97104
// 3. The user invokes their delegation.
98105
const finalInvocation = await Builder.invocationFrom(userDelegation)
99106
.audience(await Signer.generate().getDid()) // Some final service
107+
.expiresIn(remainingLifetimeMs * 0.5)
100108
.sign(userSigner); // Signed by the user
101109

102110
// Phase 2: Revoke the intermediate token (userDelegation)
@@ -151,6 +159,7 @@ describe("NilauthClient without a Payer", () => {
151159
.audience(testDid)
152160
.subject(testDid)
153161
.command("/test")
162+
.expiresIn(ONE_HOUR_MS)
154163
.sign(Signer.generate());
155164

156165
// This should succeed as it doesn't require a payer

tests/integration/signer-eip1193-provider.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EIP1193Provider } from "viem";
22
import { describe, expect, it, vi } from "vitest";
3+
import { ONE_HOUR_MS } from "#/constants";
34
import { Signer } from "#/core/signer";
45
import { Builder } from "#/nuc/builder";
56

@@ -41,6 +42,7 @@ describe("Signer.fromEip1193Provider", () => {
4142
.audience(audience)
4243
.subject(did)
4344
.command("/test")
45+
.expiresIn(ONE_HOUR_MS)
4446
.sign(nucSigner);
4547

4648
// Assert that the signer produced a signature

0 commit comments

Comments
 (0)