Skip to content

Commit 40d837e

Browse files
committed
make UCAN typing less spaghetti
1 parent 09f92c8 commit 40d837e

File tree

2 files changed

+185
-78
lines changed

2 files changed

+185
-78
lines changed

app/src/nest/qps/ucan/ucan.service.ts

Lines changed: 153 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
import { Injectable, OnModuleInit } from '@nestjs/common'
88
import * as ucans from '@ucans/ucans'
99
import { createLogger } from '../../app/logger/logger.js'
10-
import { ServerKeyManagerService } from '../../encryption/server-key-manager.service.js'
1110
import { AWSSecretsService } from '../../utils/aws/aws-secrets.service.js'
1211
import { ConfigService } from '../../utils/config/config.service.js'
1312
import { EnvironmentShort } from '../../utils/config/types.js'
1413
import {
1514
DEVICE_TOKEN_URI_SCHEME,
1615
type UcanValidationResult,
16+
type ParsedUcanPayload,
17+
type UcanCapability,
18+
type UcanResourcePointer,
19+
type UcanAbility,
1720
UcanError,
1821
UcanErrorCode,
1922
} from './ucan.types.js'
@@ -26,6 +29,12 @@ const QPS_SIGNING_KEY_SECRET_NAMES: Record<EnvironmentShort, string> = {
2629
[EnvironmentShort.Prod]: 'qps/prod-signing-key',
2730
}
2831

32+
// Far-future expiration (year 9999) - effectively no expiration
33+
// Using a number instead of Infinity because Infinity serializes to null
34+
const FAR_FUTURE_EXPIRATION = Math.floor(
35+
new Date('9999-12-31T23:59:59Z').getTime() / 1000,
36+
)
37+
2938
@Injectable()
3039
export class UcanService implements OnModuleInit {
3140
private keypair: ucans.EdKeypair | undefined
@@ -74,14 +83,10 @@ export class UcanService implements OnModuleInit {
7483

7584
// Audience is set to QPS's own DID since these are self-issued bearer tokens.
7685
// Any party holding this UCAN can present it to QPS to send push notifications.
77-
// Use a far-future expiration (year 9999) since Infinity serializes to null which breaks parsing
78-
const farFutureExpiration = Math.floor(
79-
new Date('9999-12-31T23:59:59Z').getTime() / 1000,
80-
)
8186
const ucan = await ucans.build({
8287
issuer: this.keypair,
83-
audience: this.qpsDid!, // Self-issued token - QPS is both issuer and audience
84-
expiration: farFutureExpiration, // Effectively no expiration
88+
audience: this.qpsDid!,
89+
expiration: FAR_FUTURE_EXPIRATION,
8590
capabilities: [
8691
{
8792
with: { scheme: 'fcm', hierPart: deviceToken },
@@ -109,74 +114,49 @@ export class UcanService implements OnModuleInit {
109114
}
110115

111116
try {
112-
// Parse the UCAN to inspect its contents
117+
this.logger.debug(
118+
`Validating UCAN token (length=${token.length}): ${token.substring(0, 50)}...`,
119+
)
120+
113121
const parsed = ucans.parse(token)
122+
const payload = parsed.payload as ParsedUcanPayload
114123

115-
if (parsed.payload.iss !== this.qpsDid) {
116-
this.logger.warn(
117-
`UCAN issuer mismatch: expected ${this.qpsDid}, got ${parsed.payload.iss}`,
118-
)
124+
// Verify issuer
125+
if (!this.verifyIssuer(payload)) {
119126
return {
120127
valid: false,
121128
error: 'Invalid issuer - UCAN was not issued by QPS',
122129
}
123130
}
124131

125-
try {
126-
await ucans.validate(token)
127-
} catch (validationError) {
128-
this.logger.warn(`UCAN validation failed`, validationError)
132+
// Validate signature
133+
if (!(await this.validateSignature(token))) {
129134
return {
130135
valid: false,
131136
error: 'Invalid UCAN signature or structure',
132137
}
133138
}
134139

135-
const capabilities = parsed.payload.att
136-
if (capabilities == null || capabilities.length === 0) {
137-
return {
138-
valid: false,
139-
error: 'No capabilities found in UCAN',
140-
}
141-
}
142-
143-
const pushCapability = capabilities.find(
144-
(cap: { with: unknown; can: unknown }) => {
145-
const can =
146-
typeof cap.can === 'string'
147-
? cap.can
148-
: `${(cap.can as { namespace: string; segments: string[] }).namespace}/${(cap.can as { namespace: string; segments: string[] }).segments.join('/')}`
149-
return can === 'push/send'
150-
},
151-
)
152-
140+
// Extract and validate capability
141+
const pushCapability = this.findPushCapability(payload.att)
153142
if (pushCapability == null) {
154143
return {
155144
valid: false,
156145
error: 'No push/send capability found in UCAN',
157146
}
158147
}
159148

160-
// Extract device token from the "with" field
161-
const resourceUri =
162-
typeof pushCapability.with === 'string'
163-
? pushCapability.with
164-
: `${(pushCapability.with as { scheme: string; hierPart: string }).scheme}://${(pushCapability.with as { scheme: string; hierPart: string }).hierPart}`
165-
166-
if (!resourceUri.startsWith(DEVICE_TOKEN_URI_SCHEME)) {
149+
// Extract device token from capability
150+
const deviceToken = this.extractDeviceToken(pushCapability)
151+
if (deviceToken == null) {
167152
return {
168153
valid: false,
169-
error: 'Invalid resource URI format in capability',
154+
error: 'Invalid device token in UCAN capability',
170155
}
171156
}
172157

173-
const deviceToken = resourceUri.slice(DEVICE_TOKEN_URI_SCHEME.length)
174-
175-
// Extract bundleId from facts if present
176-
const facts = parsed.payload.fct as
177-
| Array<{ bundleId?: string }>
178-
| undefined
179-
const bundleId = facts?.[0]?.bundleId
158+
// Extract bundle ID from facts
159+
const bundleId = this.extractBundleId(payload.fct)
180160

181161
return {
182162
valid: true,
@@ -195,6 +175,100 @@ export class UcanService implements OnModuleInit {
195175
}
196176
}
197177

178+
/**
179+
* Verify that the UCAN was issued by QPS
180+
*/
181+
private verifyIssuer(payload: ParsedUcanPayload): boolean {
182+
if (payload.iss !== this.qpsDid) {
183+
this.logger.warn(
184+
`UCAN issuer mismatch: expected ${this.qpsDid}, got ${payload.iss}`,
185+
)
186+
return false
187+
}
188+
return true
189+
}
190+
191+
/**
192+
* Validate the UCAN signature
193+
*/
194+
private async validateSignature(token: string): Promise<boolean> {
195+
try {
196+
await ucans.validate(token)
197+
return true
198+
} catch (validationError) {
199+
this.logger.warn(`UCAN validation failed`, validationError)
200+
return false
201+
}
202+
}
203+
204+
/**
205+
* Find the push/send capability in the capabilities array
206+
*/
207+
private findPushCapability(
208+
capabilities: UcanCapability[],
209+
): UcanCapability | null {
210+
if (capabilities.length === 0) {
211+
return null
212+
}
213+
214+
return (
215+
capabilities.find(cap => {
216+
const ability = this.normalizeAbility(cap.can)
217+
return ability === 'push/send'
218+
}) ?? null
219+
)
220+
}
221+
222+
/**
223+
* Normalize an ability to a string format
224+
*/
225+
private normalizeAbility(can: UcanAbility | string): string {
226+
if (typeof can === 'string') {
227+
return can
228+
}
229+
return `${can.namespace}/${can.segments.join('/')}`
230+
}
231+
232+
/**
233+
* Extract the device token from a push capability
234+
*/
235+
private extractDeviceToken(capability: UcanCapability): string | null {
236+
const resourceUri = this.normalizeResourcePointer(capability.with)
237+
238+
if (!resourceUri.startsWith(DEVICE_TOKEN_URI_SCHEME)) {
239+
this.logger.warn(`Invalid resource URI scheme: ${resourceUri}`)
240+
return null
241+
}
242+
243+
return resourceUri.slice(DEVICE_TOKEN_URI_SCHEME.length)
244+
}
245+
246+
/**
247+
* Normalize a resource pointer to a URI string
248+
*/
249+
private normalizeResourcePointer(
250+
resource: UcanResourcePointer | string,
251+
): string {
252+
if (typeof resource === 'string') {
253+
return resource
254+
}
255+
return `${resource.scheme}://${resource.hierPart}`
256+
}
257+
258+
/**
259+
* Extract the bundle ID from UCAN facts
260+
*/
261+
private extractBundleId(
262+
facts?: Array<Record<string, unknown>>,
263+
): string | undefined {
264+
if (facts == null || facts.length === 0) {
265+
return undefined
266+
}
267+
268+
const bundleId = facts[0].bundleId
269+
return typeof bundleId === 'string' ? bundleId : undefined
270+
}
271+
198272
/**
199273
* Initialize the QPS signing keypair
200274
* Retrieves from AWS Secrets Manager if exists, otherwise generates and stores a new one
@@ -206,29 +280,12 @@ export class UcanService implements OnModuleInit {
206280
)
207281

208282
try {
209-
// Try to retrieve existing keypair from AWS
210283
const existingSecret = await this.awsSecretsService.get(secretName)
211284

212285
if (existingSecret != null && typeof existingSecret === 'string') {
213-
this.logger.log(`Found existing QPS signing key`)
214-
// Restore keypair from stored secret (base64-encoded secret key)
215-
// EdKeypair.fromSecretKey expects a base64 string
216-
this.keypair = ucans.EdKeypair.fromSecretKey(existingSecret, {
217-
format: 'base64',
218-
exportable: true,
219-
})
286+
this.keypair = this.loadExistingKeypair(existingSecret)
220287
} else {
221-
this.logger.log(
222-
`No existing QPS signing key found, generating new keypair`,
223-
)
224-
// Generate new keypair
225-
this.keypair = await ucans.EdKeypair.create({ exportable: true })
226-
227-
// Store the secret key in AWS Secrets Manager
228-
// EdKeypair.export() returns a base64 string by default
229-
const secretKeyBase64 = await this.keypair.export('base64')
230-
await this.awsSecretsService.create(secretName, secretKeyBase64)
231-
this.logger.log(`Stored new QPS signing key in AWS Secrets Manager`)
288+
this.keypair = await this.generateAndStoreKeypair(secretName)
232289
}
233290

234291
this.qpsDid = this.keypair.did()
@@ -239,6 +296,34 @@ export class UcanService implements OnModuleInit {
239296
}
240297
}
241298

299+
/**
300+
* Load an existing keypair from a stored secret
301+
*/
302+
private loadExistingKeypair(secretKeyBase64: string): ucans.EdKeypair {
303+
this.logger.log(`Found existing QPS signing key`)
304+
return ucans.EdKeypair.fromSecretKey(secretKeyBase64, {
305+
format: 'base64',
306+
exportable: true,
307+
})
308+
}
309+
310+
/**
311+
* Generate a new keypair and store it in AWS Secrets Manager
312+
*/
313+
private async generateAndStoreKeypair(
314+
secretName: string,
315+
): Promise<ucans.EdKeypair> {
316+
this.logger.log(`No existing QPS signing key found, generating new keypair`)
317+
318+
const keypair = await ucans.EdKeypair.create({ exportable: true })
319+
const secretKeyBase64 = await keypair.export('base64')
320+
321+
await this.awsSecretsService.create(secretName, secretKeyBase64)
322+
this.logger.log(`Stored new QPS signing key in AWS Secrets Manager`)
323+
324+
return keypair
325+
}
326+
242327
/**
243328
* Get the AWS secret name for the current environment
244329
*/

app/src/nest/qps/ucan/ucan.types.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,39 @@ export interface UcanValidationResult {
3939
}
4040

4141
/**
42-
* Decoded UCAN payload structure (for internal use)
42+
* Resource pointer in a UCAN capability
4343
*/
44-
export interface DecodedUcanPayload {
45-
iss: string // Issuer DID
46-
aud: string // Audience DID or "*"
47-
exp: number | null // Expiration timestamp or null for no expiration
48-
att: Array<{
49-
with: string // Resource URI (e.g., "fcm://device-token")
50-
can: string // Capability (e.g., "push/send")
51-
}>
52-
fct?: UcanFacts // Facts
44+
export interface UcanResourcePointer {
45+
scheme: string
46+
hierPart: string
47+
}
48+
49+
/**
50+
* Ability (action) in a UCAN capability
51+
*/
52+
export interface UcanAbility {
53+
namespace: string
54+
segments: string[]
55+
}
56+
57+
/**
58+
* A single capability in a UCAN token
59+
*/
60+
export interface UcanCapability {
61+
with: UcanResourcePointer | string
62+
can: UcanAbility | string
63+
}
64+
65+
/**
66+
* Parsed UCAN payload structure
67+
*/
68+
export interface ParsedUcanPayload {
69+
iss: string
70+
aud: string
71+
exp: number | null
72+
att: UcanCapability[]
73+
fct?: Array<Record<string, unknown>>
74+
prf?: string[]
5375
}
5476

5577
/**

0 commit comments

Comments
 (0)