import * as bitcoinjs from 'bitcoinjs-lib' ; import * as crypto from 'crypto' ; import { decodeToken , TokenSigner , TokenVerifier } from 'jsontokens' ; import { ecPairToHexString , ecPairToAddress } from 'blockstack' ; import { ValidationError , AuthTokenTimestampValidationError } from './errors' ; import { logger } from './utils' ; const DEFAULT_STORAGE_URL = 'storage.blockstack.org' ; export const LATEST_AUTH_VERSION = 'v1' ; function pubkeyHexToECPair ( pubkeyHex ) { const pkBuff = Buffer . from ( pubkeyHex , 'hex' ) ; return bitcoinjs . ECPair . fromPublicKey ( pkBuff ) ; } export interface AuthScopeEntry { scope : string ; domain : string ; } export interface TokenPayloadType { gaiaChallenge : string ; iss : string ; exp : number ; iat ? : number ; salt : string ; hubUrl ? : string ; associationToken ? : string ; scopes ? : AuthScopeEntry [ ] ; childToAssociate ? : string ; } export class AuthScopeValues { writePrefixes : string [ ] = [ ] ; writePaths : string [ ] = [ ] ; deletePrefixes : string [ ] = [ ] ; deletePaths : string [ ] = [ ] ; writeArchivalPrefixes : string [ ] = [ ] ; writeArchivalPaths : string [ ] = [ ] ; static parseEntries ( scopes ) { const scopeTypes = new AuthScopeValues ( ) ; scopes . forEach ( entry => { switch ( entry . scope ) { case AuthScopesTypes . putFilePrefix : return scopeTypes . writePrefixes . push ( entry . domain ) ; case AuthScopesTypes . putFile : return scopeTypes . writePaths . push ( entry . domain ) ; case AuthScopesTypes . putFileArchival : return scopeTypes . writeArchivalPaths . push ( entry . domain ) ; case AuthScopesTypes . putFileArchivalPrefix : return scopeTypes . writeArchivalPrefixes . push ( entry . domain ) ; case AuthScopesTypes . deleteFilePrefix : return scopeTypes . deletePrefixes . push ( entry . domain ) ; case AuthScopesTypes . deleteFile : return scopeTypes . deletePaths . push ( entry . domain ) ; } } ) ; return scopeTypes ; } } export class AuthScopesTypes { static readonly putFile = 'putFile' ; static readonly putFilePrefix = 'putFilePrefix' ; static readonly deleteFile = 'deleteFile' ; static readonly deleteFilePrefix = 'deleteFilePrefix' ; static readonly putFileArchival = 'putFileArchival' ; static readonly putFileArchivalPrefix = 'putFileArchivalPrefix' ; } export const AuthScopeTypeArray = Object . values ( AuthScopesTypes ) . filter ( val => typeof val === 'string' ) ; export function getTokenPayload ( token ) { if ( typeof token . payload === 'string' ) { throw new Error ( 'Unexpected token payload type of string' ) ; } return token . payload ; } export function decodeTokenForPayload ( opts ) { try { return getTokenPayload ( decodeToken ( opts . encodedToken ) ) ; } catch ( e ) { logger . error ( `${ opts . validationErrorMsg }, ${ e }` ) ; logger . error ( opts . encodedToken ) ; throw new ValidationError ( opts . validationErrorMsg ) ; } } export interface AuthenticationInterface { checkAssociationToken ( token , bearerAddress ) : void ; getAuthenticationScopes ( ) : AuthScopeEntry [ ] ; isAuthenticationValid ( address , challengeTexts , options ? ) : string ; parseAuthScopes ( ) : AuthScopeValues ; } export class V1Authentication implements AuthenticationInterface { token : string ; constructor ( token ) { this . token = token ; } static fromAuthPart ( authPart ) { if ( ! authPart . startsWith ( 'v1:' ) ) { throw new ValidationError ( 'Authorization header should start with v1:' ) ; } const token = authPart . slice ( 'v1:' . length ) ; const payload = decodeTokenForPayload ( { encodedToken : token , validationErrorMsg : 'fromAuthPart: Failed to decode authentication JWT' } ) ; const publicKey = payload . iss ; if ( ! publicKey ) { throw new ValidationError ( 'Auth token should be a JWT with at least an `iss` claim' ) ; } const scopes = payload . scopes ; if ( scopes ) { validateScopes ( scopes ) ; } return new V1Authentication ( token ) ; } static makeAuthPart ( secretKey , challengeText , associationToken ? , hubUrl ? , scopes ? , issuedAtDate ? ) { const FOUR_MONTH_SECONDS = 60 * 60 * 24 * 31 * 4 ; const publicKeyHex = secretKey . publicKey . toString ( 'hex' ) ; const salt = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ; if ( scopes ) { validateScopes ( scopes ) ; } const payloadIssuedAtDate = issuedAtDate || ( Date . now ( ) / 1000 | 0 ) ; const payload = { gaiaChallenge : challengeText , iss : publicKeyHex , exp : FOUR_MONTH_SECONDS + ( Date . now ( ) / 1000 ) , iat : payloadIssuedAtDate , associationToken , hubUrl , salt , scopes } ; const signerKeyHex = ecPairToHexString ( secretKey ) . slice ( 0 , 64 ) ; const token = new TokenSigner ( 'ES256K' , signerKeyHex ) . sign ( payload ) ; return `v1:${ token }` ; } static makeAssociationToken ( secretKey , childPublicKey ) { const FOUR_MONTH_SECONDS = 60 * 60 * 24 * 31 * 4 ; const publicKeyHex = secretKey . publicKey . toString ( 'hex' ) ; const salt = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ; const payload = { childToAssociate : childPublicKey , iss : publicKeyHex , exp : FOUR_MONTH_SECONDS + ( Date . now ( ) / 1000 ) , iat : ( Date . now ( ) / 1000 | 0 ) , gaiaChallenge : String ( undefined ) , salt } ; const signerKeyHex = ecPairToHexString ( secretKey ) . slice ( 0 , 64 ) ; const token = new TokenSigner ( 'ES256K' , signerKeyHex ) . sign ( payload ) ; return token ; } checkAssociationToken ( token , bearerAddress ) { const payload = decodeTokenForPayload ( { encodedToken : token , validationErrorMsg : 'checkAssociationToken: Failed to decode association token in JWT' } ) ; const publicKey = payload . iss ; const childPublicKey = payload . childToAssociate ; const expiresAt = payload . exp ; if ( ! publicKey ) { throw new ValidationError ( 'Must provide `iss` claim in association JWT.' ) ; } if ( ! childPublicKey ) { throw new ValidationError ( 'Must provide `childToAssociate` claim in association JWT.' ) ; } if ( ! expiresAt ) { throw new ValidationError ( 'Must provide `exp` claim in association JWT.' ) ; } const verified = new TokenVerifier ( 'ES256K' , publicKey ) . verify ( token ) ; if ( ! verified ) { throw new ValidationError ( 'Failed to verify association JWT: invalid issuer' ) ; } if ( expiresAt < ( Date . now ( ) / 1000 ) ) { throw new ValidationError ( `Expired association token: expire time of ${ expiresAt } (secs since epoch)` ) ; } const childAddress = ecPairToAddress ( pubkeyHexToECPair ( childPublicKey ) ) ; if ( childAddress !== bearerAddress ) { throw new ValidationError ( `Association token child key ${ childPublicKey } does not match ${ bearerAddress }` ) ; } const signerAddress = ecPairToAddress ( pubkeyHexToECPair ( publicKey ) ) ; return signerAddress ; } parseAuthScopes ( ) { const scopes = this . getAuthenticationScopes ( ) ; return AuthScopeValues . parseEntries ( scopes ) ; } getAuthenticationScopes ( ) { const payload = decodeTokenForPayload ( { encodedToken : this . token , validationErrorMsg : 'getAuthenticationScopes: Failed to decode authentication JWT' } ) ; if ( ! payload [ 'scopes' ] ) { return [ ] ; } const scopes = payload . scopes . map ( ( s ) => { const r = { scope : String ( s . scope ) , domain : String ( s . domain ) } ; return r ; } ) ; return scopes ; } isAuthenticationValid ( address , challengeTexts , options ? ) { const payload = decodeTokenForPayload ( { encodedToken : this . token , validationErrorMsg : 'isAuthenticationValid: Failed to decode authentication JWT' } ) ; const publicKey = payload . iss ; const gaiaChallenge = payload . gaiaChallenge ; const scopes = payload . scopes ; if ( ! publicKey ) { throw new ValidationError ( 'Must provide `iss` claim in JWT.' ) ; } if ( options && options . oldestValidTokenTimestamp && options . oldestValidTokenTimestamp > 0 ) { const tokenIssuedAtDate = payload . iat ; const oldestValidTokenTimestamp = options . oldestValidTokenTimestamp ; if ( ! tokenIssuedAtDate ) { const message = `Gaia bucket requires auth token issued after ${ oldestValidTokenTimestamp }` + ' but this token has no creation timestamp. This token may have been revoked by the user.' ; throw new AuthTokenTimestampValidationError ( message , oldestValidTokenTimestamp ) ; } if ( tokenIssuedAtDate < options . oldestValidTokenTimestamp ) { const message = `Gaia bucket requires auth token issued after ${ oldestValidTokenTimestamp }` + ` but this token was issued ${ tokenIssuedAtDate }.` + ' This token may have been revoked by the user.' ; throw new AuthTokenTimestampValidationError ( message , oldestValidTokenTimestamp ) ; } } const issuerAddress = ecPairToAddress ( pubkeyHexToECPair ( publicKey ) ) ; if ( issuerAddress !== address ) { throw new ValidationError ( 'Address not allowed to write on this path' ) ; } if ( options && options . requireCorrectHubUrl ) { let claimedHub = payload . hubUrl ; if ( ! claimedHub ) { throw new ValidationError ( 'Authentication must provide a claimed hub. You may need to update blockstack.js.' ) ; } if ( claimedHub . endsWith ( '/' ) ) { claimedHub = claimedHub . slice ( 0 , - 1 ) ; } const validHubUrls = options . validHubUrls ; if ( ! validHubUrls ) { throw new ValidationError ( 'Configuration error on the gaia hub. validHubUrls must be supplied.' ) ; } if ( ! validHubUrls . includes ( claimedHub ) ) { throw new ValidationError ( `Auth token's claimed hub url '${ claimedHub }' not found` + ` in this hubs set: ${ JSON . stringify ( validHubUrls ) }` ) ; } } if ( scopes ) { validateScopes ( scopes ) ; } let verified ; try { verified = new TokenVerifier ( 'ES256K' , publicKey ) . verify ( this . token ) ; } catch ( err ) { throw new ValidationError ( 'Failed to verify supplied authentication JWT' ) ; } if ( ! verified ) { throw new ValidationError ( 'Failed to verify supplied authentication JWT' ) ; } if ( ! challengeTexts . includes ( gaiaChallenge ) ) { throw new ValidationError ( `Invalid gaiaChallenge text in supplied JWT: "${ gaiaChallenge }"` + ` not found in ${ JSON . stringify ( challengeTexts ) }` ) ; } const expiresAt = payload . exp ; if ( expiresAt && expiresAt < ( Date . now ( ) / 1000 ) ) { throw new ValidationError ( `Expired authentication token: expire time of ${ expiresAt } (secs since epoch)` ) ; } if ( 'associationToken' in payload && payload . associationToken ) { return this . checkAssociationToken ( payload . associationToken , address ) ; } else { return address ; } } } export class LegacyAuthentication implements AuthenticationInterface { checkAssociationToken ( _token , _bearerAddress ) { throw new Error ( 'Method not implemented.' ) ; } parseAuthScopes ( ) { return new AuthScopeValues ( ) ; } publickey : bitcoinjs . ECPairInterface ; signature : string ; constructor ( publickey , signature ) { this . publickey = publickey ; this . signature = signature ; } static fromAuthPart ( authPart ) { const decoded = JSON . parse ( Buffer . from ( authPart , 'base64' ) . toString ( ) ) ; const publickey = pubkeyHexToECPair ( decoded . publickey ) ; const hashType = Buffer . from ( [ bitcoinjs . Transaction . SIGHASH_NONE ] ) ; const signatureBuffer = Buffer . concat ( [ Buffer . from ( decoded . signature , 'hex' ) , hashType ] ) ; const signature = bitcoinjs . script . signature . decode ( signatureBuffer ) . signature . toString ( 'hex' ) ; return new LegacyAuthentication ( publickey , signature ) ; } static makeAuthPart ( secretKey , challengeText ) { const publickey = secretKey . publicKey . toString ( 'hex' ) ; const digest = bitcoinjs . crypto . sha256 ( Buffer . from ( challengeText ) ) ; const signatureBuffer = secretKey . sign ( digest ) ; const signatureWithHash = bitcoinjs . script . signature . encode ( signatureBuffer , bitcoinjs . Transaction . SIGHASH_NONE ) ; const signature = signatureWithHash . toString ( 'hex' ) . slice ( 0 , - 2 ) ; const authObj = { publickey , signature } ; return Buffer . from ( JSON . stringify ( authObj ) ) . toString ( 'base64' ) ; } getAuthenticationScopes ( ) { return [ ] ; } isAuthenticationValid ( address , challengeTexts , options ? ) { if ( ecPairToAddress ( this . publickey ) !== address ) { throw new ValidationError ( 'Address not allowed to write on this path' ) ; } for ( const challengeText of challengeTexts ) { const digest = bitcoinjs . crypto . sha256 ( Buffer . from ( challengeText ) ) ; const valid = ( this . publickey . verify ( digest , Buffer . from ( this . signature , 'hex' ) ) === true ) ; if ( valid ) { return address ; } } logger . debug ( `Failed to validate with challenge text: ${ JSON . stringify ( challengeTexts ) }` ) ; throw new ValidationError ( 'Invalid signature or expired authentication token.' ) ; } } export function getChallengeText ( myURL = DEFAULT_STORAGE_URL ) { const header = 'gaiahub' ; const allowedSpan = '0' ; const myChallenge = 'blockstack_storage_please_sign' ; return JSON . stringify ( [ header , allowedSpan , myURL , myChallenge ] ) ; } export function getLegacyChallengeTexts ( myURL = DEFAULT_STORAGE_URL ) { const header = 'gaiahub' ; const myChallenge = 'blockstack_storage_please_sign' ; const legacyYears = [ '2018' , '2019' ] ; return legacyYears . map ( year => JSON . stringify ( [ header , year , myURL , myChallenge ] ) ) ; } export function parseAuthHeader ( authHeader ) { if ( ! authHeader || ! authHeader . toLowerCase ( ) . startsWith ( 'bearer' ) ) { throw new ValidationError ( 'Failed to parse authentication header.' ) ; } const authPart = authHeader . slice ( 'bearer ' . length ) ; const versionIndex = authPart . indexOf ( ':' ) ; if ( versionIndex < 0 ) { return LegacyAuthentication . fromAuthPart ( authPart ) ; } else { const version = authPart . slice ( 0 , versionIndex ) ; if ( version === 'v1' ) { return V1Authentication . fromAuthPart ( authPart ) ; } else { throw new ValidationError ( 'Unknown authentication header version: ${version}' ) ; } } } export function validateAuthorizationHeader ( authHeader , serverName , address , requireCorrectHubUrl = false , validHubUrls = null , oldestValidTokenTimestamp ? ) { const serverNameHubUrl = `https://${ serverName }` ; if ( ! validHubUrls ) { validHubUrls = [ serverNameHubUrl ] ; } else if ( ! validHubUrls . includes ( serverNameHubUrl ) ) { validHubUrls . push ( serverNameHubUrl ) ; } let authObject = null ; try { authObject = parseAuthHeader ( authHeader ) ; } catch ( err ) { logger . error ( err ) ; } if ( ! authObject ) { throw new ValidationError ( 'Failed to parse authentication header.' ) ; } const challengeTexts = [ ] ; challengeTexts . push ( getChallengeText ( serverName ) ) ; getLegacyChallengeTexts ( serverName ) . forEach ( challengeText => challengeTexts . push ( challengeText ) ) ; return authObject . isAuthenticationValid ( address , challengeTexts , { validHubUrls , requireCorrectHubUrl , oldestValidTokenTimestamp } ) ; } export function getAuthenticationScopes ( authHeader ) { const authObject = parseAuthHeader ( authHeader ) ; return authObject . parseAuthScopes ( ) ; } function validateScopes ( scopes ) { if ( scopes . length > 8 ) { throw new ValidationError ( 'Too many authentication scopes' ) ; } for ( let i = 0 ; i < scopes . length ; i ++ ) { const scope = scopes [ i ] ; const found = AuthScopeTypeArray . find ( ( s ) => ( s === scope . scope ) ) ; if ( ! found ) { throw new ValidationError ( `Unrecognized scope ${ scope . scope }` ) ; } } return true ; }