77import { Injectable , OnModuleInit } from '@nestjs/common'
88import * as ucans from '@ucans/ucans'
99import { createLogger } from '../../app/logger/logger.js'
10- import { ServerKeyManagerService } from '../../encryption/server-key-manager.service.js'
1110import { AWSSecretsService } from '../../utils/aws/aws-secrets.service.js'
1211import { ConfigService } from '../../utils/config/config.service.js'
1312import { EnvironmentShort } from '../../utils/config/types.js'
1413import {
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 ( )
3039export 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 */
0 commit comments