diff --git a/architecture/api/admin/admin.use-case.puml b/architecture/api/admin/admin.use-case.puml index a4b57ac..8eef06e 100644 --- a/architecture/api/admin/admin.use-case.puml +++ b/architecture/api/admin/admin.use-case.puml @@ -7,22 +7,32 @@ left to right direction user <|-- root : is a package "User End-Point" { - user --> (change details) : self - note right: PATCH /user/:user {emails, keyids} - note bottom of "change details": including revoke key - (get activation) #lightgreen - user --> (get activation) - note right of "get activation": sends code by email - note right of "get activation": POST /user/:user/activation {email} - (get user key) #lightgreen - user --> (get user key) : self; can use\nactivation - note top of "get user key": must use activation\nfor self-service create - note right of "get user key": create user account\nif not exists - note right of "get user key": POST /user/:user/key - root --> (get user key) : any user - user --> (delete user) : self - root --> (delete user) : any user - note right: DEL /user/:user + usecase changeDetails as "=== change details + PATCH /user/:user {emails, keyids}" + user --> (changeDetails) : self + note right: including revoke key + + usecase getActivation #lightgreen as "=== get activation + POST /user/:user/activation {email}" + user --> (getActivation) + note right: sends code by email + + usecase mintUserKey #lightgreen as "=== mint user key + POST /user/:user/key" + user --> mintUserKey : self; can use\nactivation + note right of mintUserKey: must use activation\nfor self-service create + note right of mintUserKey: create user account\nif not exists + root --> mintUserKey : any user + + usecase deleteUser as "=== delete user + DEL /user/:user" + user --> deleteUser : self + root --> deleteUser : any user + + usecase getPublicKey as "=== get public key + GET /user/:user/publicKey/:keyid" + user --> getPublicKey : self + root --> getPublicKey : any user } @enduml \ No newline at end of file diff --git a/architecture/api/admin/img/admin.use-case.svg b/architecture/api/admin/img/admin.use-case.svg index 64c6535..46ee1d3 100644 --- a/architecture/api/admin/img/admin.use-case.svg +++ b/architecture/api/admin/img/admin.use-case.svg @@ -1,14 +1,16 @@ -User End-Pointchange detailsPATCH /user/:user {emails, keyids}including revoke keyget activationsends code by emailPOST /user/:user/activation {email}get user keymust use activationfor self-service createcreate user accountif not existsPOST /user/:user/keydelete userDEL /user/:useruserrootis aselfself; can useactivationany userselfany userUser End-Pointchange detailsPATCH /user/:user {emails, keyids}including revoke keyget activationPOST /user/:user/activation {email}sends code by emailmint user keyPOST /user/:user/keymust use activationfor self-service createcreate user accountif not existsdelete userDEL /user/:userget public keyGET /user/:user/publicKey/:keyiduserrootis aselfself; can useactivationany userselfany userselfany user idp: Authenticate user (or anon) +client -> service ++ : get config for subdomain {userId} +service -> Gateway ++: PUT /domain/<>/<>\n{authKey, userId} +Gateway -> Gateway: Create user JWT,\nsign with authKey return config for new clones note left { @domain:"<>.<>.<>", genesis:true, - io + io: { auth: { jwt } } } end note @@ -23,9 +27,7 @@ if backup clone or domain exists, genesis is false end note -client <--> service: Authenticate user (or anon) -service -> service: Create client JWT,\nsign with authKey -service -> client: ""{config, jwt}"" +return config client -> client ++: Create clone client <--> Gateway: socket.io remotes ""{jwt}"" diff --git a/architecture/api/domain/img/account-domain.seq.svg b/architecture/api/domain/img/account-domain.seq.svg index 90ba3f3..6026b9e 100644 --- a/architecture/api/domain/img/account-domain.seq.svg +++ b/architecture/api/domain/img/account-domain.seq.svg @@ -1,8 +1,4 @@ - -App with Gateway accountAppClientApp Serviceor lambdaGatewayPUT /domain/<<account>>/<<name>>{authKey}config for new clones{@domain:"«name».«account».«hostname»",genesis:true,io}if backup cloneor domain exists,genesis is falseAuthenticate user (or anon)Create client JWT,sign with authKey{config, jwt}Create clonesocket.io remotes{jwt} Gateway: socket.io remotes (rate limited) diff --git a/architecture/gateway design.mm b/architecture/gateway design.mm index bc6a1ce..814c8f9 100644 --- a/architecture/gateway design.mm +++ b/architecture/gateway design.mm @@ -320,7 +320,7 @@ - + diff --git a/src/ably/AblyCloneFactory.ts b/src/ably/AblyCloneFactory.ts index 39f0da8..4bc388b 100644 --- a/src/ably/AblyCloneFactory.ts +++ b/src/ably/AblyCloneFactory.ts @@ -1,8 +1,8 @@ import * as ablyModule from '@m-ld/m-ld/ext/ably'; -import { BaseGatewayConfig, CloneFactory, Env, GatewayPrincipal } from '../lib/index.js'; +import { + BaseGatewayConfig, CloneFactory, ConfigContext, Env, GatewayPrincipal +} from '../lib/index.js'; import * as xirsys from '@m-ld/io-web-runtime/dist/server/xirsys'; -import { Who } from '../server/index.js'; -import { RemotesAuthType } from '../server/Account.js'; type AblyGatewayConfig = BaseGatewayConfig & ablyModule.MeldAblyConfig; @@ -32,13 +32,9 @@ export class AblyCloneFactory extends CloneFactory { return ablyModule.AblyRemotes; } - async reusableConfig( - config: BaseGatewayConfig, - remotesAuth: RemotesAuthType[], - who?: Who - ): Promise> { + async reusableConfig(config: BaseGatewayConfig, context: ConfigContext) { const { ably } = config; - return Env.mergeConfig(super.reusableConfig(config, remotesAuth, who), { ably }, { + return Env.mergeConfig(super.reusableConfig(config, context), { ably }, { ably: { key: false, apiKey: false } // Remove Ably secrets }); } diff --git a/src/data/UserKey.ts b/src/data/UserKey.ts index c382ab9..54c34a2 100644 --- a/src/data/UserKey.ts +++ b/src/data/UserKey.ts @@ -4,17 +4,34 @@ import { PublicKeyInput, RSAKeyPairOptions, sign, verify } from 'crypto'; import { AuthKey, AuthKeyConfig, domainRelativeIri, Key } from '../lib/index.js'; -import { JwtHeader, Secret, SignOptions } from 'jsonwebtoken'; +import { JwtHeader, JwtPayload, Secret, SignOptions } from 'jsonwebtoken'; import { signJwt, verifyJwt } from '@m-ld/io-web-runtime/dist/server/jwt'; +import { as } from '../lib/validate.js'; -export interface UserKeyConfig extends AuthKeyConfig { - key: { - type: 'rsa', - public: string, - private?: string +export interface RsaKeyConfig { + type: 'rsa'; + public: string; + private?: string; +} + +export const asRsaKeyConfig = as.object({ + type: as.equal('rsa').default('rsa'), + public: as.string().base64().required(), + private: as.string().base64().optional() +}); + +export function keyPairFromConfig(config: RsaKeyConfig) { + return { + publicKey: Buffer.from(config.public, 'base64'), + privateKey: config.private ? + Buffer.from(config.private, 'base64') : undefined }; } +export interface UserKeyConfig extends AuthKeyConfig { + key: RsaKeyConfig; +} + /** * User Key details, appears in: * 1. Gateway domain, with all details @@ -42,9 +59,7 @@ export class UserKey implements Key { static fromConfig(config: UserKeyConfig) { return new UserKey({ keyid: AuthKey.fromString(config.auth.key).keyid, - publicKey: Buffer.from(config.key.public, 'base64'), - privateKey: config.key.private ? - Buffer.from(config.key.private, 'base64') : undefined + ...keyPairFromConfig(config.key) }); } @@ -81,7 +96,7 @@ export class UserKey implements Key { return new UserKey({ keyid: authKey.keyid, ...generateKeyPairSync('rsa', >{ - modulusLength: 1024, + modulusLength: 2048, publicKeyEncoding: this.encoding.public, privateKeyEncoding: this.encoding.private(authKey) }) @@ -119,7 +134,7 @@ export class UserKey implements Key { } /** - * @returns {boolean} `false` if the auth key does not correspond to this user key + * @returns `false` if the auth key does not correspond to this user key */ matches(authKey: AuthKey) { if (authKey.keyid !== this.keyid) @@ -146,10 +161,8 @@ export class UserKey implements Key { return verify('RSA-SHA256', data, this.getCryptoPublicKey(), cryptoSig); } - /** - * @returns {Promise} JWT - */ - signJwt(payload: string | Buffer | object, authKey: AuthKey, options?: SignOptions) { + /** @returns JWT */ + signJwt(payload: string | Buffer | JwtPayload, authKey: AuthKey, options?: SignOptions) { // noinspection JSCheckFunctionSignatures return signJwt(payload, this.getCryptoPrivateKey(authKey) as unknown as Secret, { ...options, algorithm: 'RS256', keyid: this.keyid @@ -171,12 +184,16 @@ export class UserKey implements Key { return ['rsa-v1_5-sha256', this.getCryptoPrivateKey(authKey)]; } - private getCryptoPrivateKey(authKey: AuthKey): KeyObject { + private get surePrivateKey() { if (this.privateKey == null) throw new RangeError('Private key unavailable'); + return this.privateKey; + } + + private getCryptoPrivateKey(authKey: AuthKey): KeyObject { // noinspection JSCheckFunctionSignatures return createPrivateKey({ - key: this.privateKey, + key: this.surePrivateKey, ...UserKey.encoding.private(authKey) }); } @@ -189,10 +206,9 @@ export class UserKey implements Key { } /** - * @param {boolean} excludePrivate `true` to exclude the private key - * @returns {GraphSubject} + * @param excludePrivate `true` to exclude the private key */ - toJSON(excludePrivate = false): any { + toJSON(excludePrivate = false): GraphSubject { // noinspection JSValidateTypes return { ...UserKey.refFromKeyid(this.keyid), diff --git a/src/http/ApiEndPoint.ts b/src/http/ApiEndPoint.ts index 76b5aed..53ce3b1 100644 --- a/src/http/ApiEndPoint.ts +++ b/src/http/ApiEndPoint.ts @@ -24,9 +24,7 @@ export class ApiEndPoint extends EndPoint { @get('/publicKey') async getPublicKey(_req: Request, res: Response) { - res.contentType = 'text'; - res.send(this.gateway.me.userKey!.getCryptoPublicKey().export({ - format: 'pem', type: 'spki' - })); + res.contentType = 'application/x-pem-file'; + res.send(this.gateway.me.userKey.getCryptoPublicKey()); } } \ No newline at end of file diff --git a/src/http/EndPoint.ts b/src/http/EndPoint.ts index dce5960..f5061c6 100644 --- a/src/http/EndPoint.ts +++ b/src/http/EndPoint.ts @@ -8,9 +8,11 @@ import { pipeline } from 'stream/promises'; import { Consumable } from 'rx-flowable'; import 'reflect-metadata'; -export const formatter = (format: StringFormat): Formatter => { +export const stringFormatter = (format?: StringFormat): Formatter => { return (_req, res, body) => { - const data = `${format.opening || ''}${format.stringify(body)}${format.closing || ''}`; + const data = (format?.opening ?? '') + + (format?.stringify(body) ?? `${body}`) + + (format?.closing ?? ''); res.setHeader('Content-Length', Buffer.byteLength(data)); return data; }; @@ -24,6 +26,10 @@ export const HTML_FORMAT: StringFormat = { stringify: s => JSON.stringify(s, null, ' '), opening: '
', closing: '
', separator: '\n' }; +export const PEM_FORMAT: StringFormat = { + stringify: s => typeof s == 'string' ? s : + s.export({ format: 'pem', type: 'spki' }) +}; export async function sendChunked(res: Response, results: Consumable, status = 200) { res.header('transfer-encoding', 'chunked'); diff --git a/src/http/SubdomainEndPoint.ts b/src/http/SubdomainEndPoint.ts index f449880..030d870 100644 --- a/src/http/SubdomainEndPoint.ts +++ b/src/http/SubdomainEndPoint.ts @@ -7,6 +7,7 @@ import { consume } from 'rx-flowable/consume'; import { Readable } from 'stream'; import { SubdomainClone } from '../server/SubdomainClone.js'; import { Request, Response } from 'restify'; +import { asRsaKeyConfig, keyPairFromConfig, UserKey } from '../data/UserKey.js'; export type SubdomainRequest = Request & HasContext<'id', AccountOwnedId> & @@ -41,13 +42,22 @@ export class SubdomainEndPoint extends EndPoint { @put async putSubdomain(req: SubdomainRequest, res: Response) { - const { useSignatures } = validate(req.body ?? {}, as.object({ - useSignatures: as.boolean().optional() + const { useSignatures, user } = validate(req.body ?? {}, as.object({ + useSignatures: as.boolean().optional(), + user: as.object({ + '@id': as.string().uri().required(), + key: asRsaKeyConfig + .keys({ '@id': as.string().uri().required() }) + .custom(json => new UserKey({ + keyid: json['@id'], ...keyPairFromConfig(json) + })).optional() + }).optional() })); const { account, name } = req.get('id'); - res.json(await this.gateway.ensureNamedSubdomain({ - useSignatures, account, name - }, req.get('who'))); + res.json(await this.gateway.ensureNamedSubdomain( + { useSignatures, account, name }, + { ...req.get('who'), user } + )); } @post('/poll') diff --git a/src/http/UserEndPoint.ts b/src/http/UserEndPoint.ts index 378a289..98c3bc1 100644 --- a/src/http/UserEndPoint.ts +++ b/src/http/UserEndPoint.ts @@ -1,4 +1,4 @@ -import { EndPoint, HasContext, patch, post, use } from './EndPoint.js'; +import { EndPoint, get, HasContext, patch, post, use } from './EndPoint.js'; import { ApiEndPoint } from './ApiEndPoint.js'; import { ForbiddenError, NotFoundError, UnauthorizedError } from './errors.js'; import { Authorization, Notifier } from '../server/index.js'; @@ -62,6 +62,15 @@ export class UserEndPoint extends EndPoint { res.json(200, { jwe }); } + @get('/publicKey/:keyid') + async getPublicKey(req: UserRequest, res: Response) { + const { keyid } = req.params; + const acc = await this.getAuthorisedAccount(req, false); + const userKey = await acc.getUserKey(keyid); + res.contentType = 'application/x-pem-file'; + res.send(userKey.getCryptoPublicKey()); + } + @patch async updateDetails(req: UserRequest, res: Response) { const acc = await this.getAuthorisedAccount(req, false); diff --git a/src/http/index.ts b/src/http/index.ts index 710d3de..36fba36 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,6 +1,6 @@ import { createServer, plugins, pre, Server as RestServer } from 'restify'; import LOG from 'loglevel'; -import { formatter, HTML_FORMAT, JSON_LD_FORMAT } from './EndPoint.js'; +import { HTML_FORMAT, JSON_LD_FORMAT, PEM_FORMAT, stringFormatter } from './EndPoint.js'; import { ApiEndPoint } from './ApiEndPoint.js'; import { SubdomainEndPoint } from './SubdomainEndPoint.js'; import { SubdomainStateEndPoint } from './SubdomainStateEndPoint.js'; @@ -17,8 +17,9 @@ export function setupGatewayHttp({ gateway, notifier, liquid }: { }): RestServer { const server = createServer({ formatters: { - 'application/ld+json': formatter(JSON_LD_FORMAT), - 'text/html': formatter(HTML_FORMAT) + 'application/ld+json': stringFormatter(JSON_LD_FORMAT), + 'text/html': stringFormatter(HTML_FORMAT), + 'application/x-pem-file': stringFormatter(PEM_FORMAT) } }).pre(pre.context()) .use(plugins.queryParser({ mapParams: true })) diff --git a/src/lib/CloneFactory.ts b/src/lib/CloneFactory.ts index 139744f..d557374 100644 --- a/src/lib/CloneFactory.ts +++ b/src/lib/CloneFactory.ts @@ -15,6 +15,12 @@ import { RemotesAuthType } from '../server/Account.js'; export type BackendLevel = AbstractLevel; +export interface ConfigContext { + who?: Who, + remotesAuth: RemotesAuthType[], + mintJwt?(): Promise +} + export abstract class CloneFactory { async clone( config: BaseGatewayConfig, @@ -50,8 +56,7 @@ export abstract class CloneFactory { */ async reusableConfig( config: BaseGatewayConfig, - remotesAuth: RemotesAuthType[], - who?: Who + _context: ConfigContext ): Promise> { const { networkTimeout, maxOperationSize, logLevel } = config; return { networkTimeout, maxOperationSize, logLevel }; diff --git a/src/lib/StringReadable.ts b/src/lib/StringReadable.ts index 9107179..eb7500e 100644 --- a/src/lib/StringReadable.ts +++ b/src/lib/StringReadable.ts @@ -5,7 +5,7 @@ import { Consumable } from 'rx-flowable'; export interface StringFormat { opening?: string; closing?: string; - separator: string; + separator?: string; stringify(value: any): string; } @@ -27,7 +27,7 @@ export class StringReadable extends Readable { next: async bite => { openIfRequired(); const subjectStr = format.stringify(bite.value); - this.push(Buffer.from(`${this.index++ ? format.separator : ''}${subjectStr}`)); + this.push(Buffer.from(`${(this.index++ && format.separator) || ''}${subjectStr}`)); this.next = bite.next; }, complete: () => { @@ -42,7 +42,7 @@ export class StringReadable extends Readable { }); } - _read(size: number) { + _read(_size: number) { if (this.next) { this.next(); delete this.next; diff --git a/src/server/Account.ts b/src/server/Account.ts index f6e07ff..6e5d23d 100644 --- a/src/server/Account.ts +++ b/src/server/Account.ts @@ -141,6 +141,15 @@ export class Account { return config; } + /** + * Checks that the given keyid belongs to this account and returns the + * corresponding user key + * @throws UnauthorizedError if the key does not belong to this account + */ + async getUserKey(keyid: string): Promise { + return this.gateway.domain.read(state => this.key(state, keyid)); + } + /** * TODO: Refactor awkward return type * @returns the user key, if the gateway is using user keys diff --git a/src/server/Authorization.ts b/src/server/Authorization.ts index a1d4e2f..ede9c0c 100644 --- a/src/server/Authorization.ts +++ b/src/server/Authorization.ts @@ -1,8 +1,8 @@ -import { AccountOwnedId, AuthKey } from '../lib/index.js'; -import { UserKey } from '../data/index.js'; +import { AccountOwnedId, AuthKey, lastPathComponent } from '../lib/index.js'; +import { Iri, UserKey } from '../data/index.js'; import { UnauthorizedError } from '../http/errors.js'; import type { Request } from 'restify'; -import { Gateway, Who } from './Gateway.js'; +import { Gateway } from './Gateway.js'; import { decode } from 'jsonwebtoken'; import { Account } from './Account.js'; @@ -16,7 +16,29 @@ export interface AccessRequest { forWrite?: string; } +/** + * A security principal in the Gateway in the context of a session. When a `Who` + * is used in the code, we should already have authenticated and authorised the + * account and user, as required. + */ +export interface Who { + readonly acc: Account, + /** + * The full account auth key may or may not be known in a session context. + */ + readonly key: { keyid: string } | AuthKey, + /** + * An end-user. This may not be the account, if the application is providing + * its own access control. If the @id is relative, it will be resolved against + * the Gateway domain. + */ + readonly user?: { '@id': Iri, key?: UserKey } +} + export abstract class Authorization { + /** Available if the authorization includes the user's auth key */ + authKey?: AuthKey; + static fromRequest(req: Request) { if (req.authorization == null) throw new UnauthorizedError; @@ -36,15 +58,15 @@ export abstract class Authorization { * @param [access] a timesheet or project access request * @returns {} */ - abstract verifyUser( - gateway: Gateway, - access?: AccessRequest - ): Promise<{ acc: Account, keyid: string }>; + abstract verifyUser(gateway: Gateway, access?: AccessRequest): Promise; protected async getUserAccount(gateway: Gateway, user: string) { - if (!AccountOwnedId.isComponentId(user)) - throw new UnauthorizedError('Bad user %s', user); - const userAcc = await gateway.account(user); + // Check for an absolute user URI e.g. http://gateway/name + const name = lastPathComponent(user); + if (!AccountOwnedId.isComponentId(name) || + (user !== name && user !== gateway.absoluteId(name))) + throw new UnauthorizedError('Bad user: %s', user); + const userAcc = await gateway.account(name); if (userAcc == null) throw new UnauthorizedError('Not found: %s', user); return userAcc; @@ -52,24 +74,26 @@ export abstract class Authorization { } export class KeyAuthorization extends Authorization { + readonly authKey: AuthKey; + /** * @param user * @param key an authorisation key associated with this Account */ constructor( private readonly user: string, - private readonly key: string + key: string ) { super(); + this.authKey = AuthKey.fromString(key); } async verifyUser(gateway: Gateway, access?: AccessRequest) { const userAcc = await this.getUserAccount(gateway, this.user); - const authKey = AuthKey.fromString(this.key); - const userKey = await userAcc.authorise(authKey.keyid, access); - if (!userKey.matches(authKey)) + const userKey = await userAcc.authorise(this.authKey.keyid, access); + if (!userKey.matches(this.authKey)) throw new UnauthorizedError; - return { acc: userAcc, keyid: userKey.keyid }; + return { acc: userAcc, key: this.authKey }; } } @@ -83,9 +107,9 @@ export class JwtAuthorization extends Authorization { async verifyUser(gateway: Gateway, access?: AccessRequest): Promise { const payload = decode(this.jwt, { json: true }); - if (payload?.sub == null) - throw new UnauthorizedError('Missing user identity'); - const userAcc = await this.getUserAccount(gateway, payload.sub); + if (payload?.iss == null) + throw new UnauthorizedError('Missing JWT issuer identity'); + const userAcc = await this.getUserAccount(gateway, payload.iss); let keyid: string; try { // Verify the JWT against its declared keyid await UserKey.verifyJwt(this.jwt, async header => @@ -93,6 +117,6 @@ export class JwtAuthorization extends Authorization { } catch (e) { throw new UnauthorizedError(e); } - return { acc: userAcc, keyid: keyid! }; + return { acc: userAcc, key: { keyid: keyid! } }; } } \ No newline at end of file diff --git a/src/server/Gateway.ts b/src/server/Gateway.ts index 6296274..1616869 100644 --- a/src/server/Gateway.ts +++ b/src/server/Gateway.ts @@ -2,8 +2,8 @@ import { MeldClone, MeldConfig, MeldReadState, MeldUpdate, propertyValue, Reference, uuid } from '@m-ld/m-ld'; import { - AccountOwnedId, asUuid, BaseGateway, BaseGatewayConfig, CloneFactory, Env, GatewayPrincipal, - KeyStore, matches + AccountOwnedId, asUuid, AuthKey, BaseGateway, BaseGatewayConfig, CloneFactory, ConfigContext, Env, + GatewayPrincipal, KeyStore, matches } from '../lib/index.js'; import { gatewayContext, Iri, UserKey } from '../data/index.js'; import LOG from 'loglevel'; @@ -16,15 +16,11 @@ import { GatewayConfig } from './index.js'; import { Account, AccountContext } from './Account.js'; import { SubdomainClone } from './SubdomainClone.js'; import { randomInt } from 'crypto'; -import jsonwebtoken, { JwtPayload } from 'jsonwebtoken'; +import jsonwebtoken, { JwtPayload, SignOptions } from 'jsonwebtoken'; import Cryptr from 'cryptr'; import { Subdomain, SubdomainSpec } from '../data/Subdomain.js'; import { SubdomainCache } from './SubdomainCache'; - -export interface Who { - acc: Account, - keyid: string -} +import { Who } from './Authorization'; export class Gateway extends BaseGateway implements AccountContext { public readonly me: GatewayPrincipal; @@ -92,10 +88,10 @@ export class Gateway extends BaseGateway implements AccountContext { * @param type note vocabulary is common between gw and ts * @param key */ - async writePrincipalToSubdomain( + private async writePrincipalToSubdomain( sd: SubdomainClone, iri: Iri, - type: 'Account' | 'Gateway', + type: 'Account' | 'Gateway' | 'User', key: UserKey ) { await sd.write({ @@ -106,6 +102,31 @@ export class Gateway extends BaseGateway implements AccountContext { await sd.unlock(); } + /** + * Ensures that the user account is in the subdomain for signing + * @param state + * @param sd + * @param who + * @private + */ + private async writeUserToSubdomain( + state: MeldReadState, + sd: SubdomainClone, + who: Who + ) { + if (who.user != null) { + if (who.user.key == null) + throw new BadRequestError('User key is required for signatures'); + await this.writePrincipalToSubdomain( + sd, who.user['@id'], 'User', who.user.key); + } else { + // Use the account as the user + const userKey = await who.acc.key(state, who.key.keyid); + await this.writePrincipalToSubdomain( + sd, who.acc.name, 'Account', userKey); + } + } + getDataPath(id: AccountOwnedId) { return this.env.readyPath('data', 'domain', id.account, id.name); } @@ -214,12 +235,8 @@ export class Gateway extends BaseGateway implements AccountContext { state = await state.write({ '@id': id.account, subdomain: sdClone.toJSON() }); - if (sdClone.useSignatures && who != null) { - // Ensure that the user account is in the subdomain for signing - const userKey = await who.acc.key(state, who.keyid); - await this.writePrincipalToSubdomain( - sdClone, who.acc.name, 'Account', userKey); - } + if (sdClone.useSignatures && who != null) + await this.writeUserToSubdomain(state, sdClone, who); this.subdomainCache.set(id.toDomain(), sdClone); } else if (src != null && spec.useSignatures != null && @@ -241,28 +258,41 @@ export class Gateway extends BaseGateway implements AccountContext { genesis?: boolean, who?: Who ): Promise> { - return new Promise((resolve, reject) => { - this.domain.read(async state => { - try { - const remotesAuth = - await Account.getDetails(state, id.account, 'remotesAuth'); - if (genesis == null) { - if (await state.ask({ '@where': { '@id': id.toRelativeIri() } })) { - genesis = false; // We have a backup - } else if (!matches(id.name, asUuid) || - !await Account.allowsUuidSubdomains(state, id.account)) { - return reject(new NotFoundError); // UUID domains not allowed - } // Otherwise, don't know - } - resolve({ - '@domain': id.toDomain(), genesis, - ...await this.cloneFactory.reusableConfig(this.config, remotesAuth, who) - }); - } catch (e) { - reject(e); - } - }); - }); + return Promise.resolve(this.domain.read(async state => { + const remotesAuth = + await Account.getDetails(state, id.account, 'remotesAuth'); + if (genesis == null) { + if (await state.ask({ '@where': { '@id': id.toRelativeIri() } })) { + genesis = false; // We have a backup + } else if (!matches(id.name, asUuid) || + !await Account.allowsUuidSubdomains(state, id.account)) { + throw new NotFoundError; // UUID domains not allowed + } // Otherwise, don't know + } + const mintJwt = who && this.getMintJwt(state, who); + return { + '@domain': id.toDomain(), genesis, + ...await this.cloneFactory.reusableConfig(this.config, { + who, remotesAuth, mintJwt + }) + }; + })); + } + + private getMintJwt(state: MeldReadState, who: Who): ConfigContext['mintJwt'] | undefined { + if (who.key instanceof AuthKey) { + const authKey = who.key; + return async () => { + const userKey = await who.acc.key(state, who.key.keyid); + const signOptions: SignOptions = { + issuer: this.absoluteId(who.acc.name), + expiresIn: '10m' + }; + if (who.user != null) + signOptions.subject = who.user['@id']; + return userKey.signJwt({}, authKey, signOptions); + }; + } } getSubdomain(id: AccountOwnedId): Promise { diff --git a/src/server/index.ts b/src/server/index.ts index d2f22da..41f4c0d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,8 +15,9 @@ export interface GatewayConfig extends BaseGatewayConfig, UserKeyConfig { subdomainCacheSize?: number; } -export { Gateway, Who } from './Gateway.js'; +export { Gateway } from './Gateway.js'; export { Authorization } from './Authorization.js'; export { Account } from './Account.js'; export { Notifier } from './Notifier.js'; -export { SubdomainCache } from './SubdomainCache.js'; \ No newline at end of file +export { SubdomainCache } from './SubdomainCache.js'; +export { Who } from './Authorization'; \ No newline at end of file diff --git a/src/socket.io/IoCloneFactory.ts b/src/socket.io/IoCloneFactory.ts index d005576..97750f8 100644 --- a/src/socket.io/IoCloneFactory.ts +++ b/src/socket.io/IoCloneFactory.ts @@ -1,10 +1,8 @@ import { - BaseGatewayConfig, CloneFactory, Env, GatewayPrincipal, resolveGateway + BaseGatewayConfig, CloneFactory, ConfigContext, Env, GatewayPrincipal, resolveGateway } from '../index.js'; import { IoRemotes, MeldIoConfig } from '@m-ld/m-ld/ext/socket.io'; import LOG from 'loglevel'; -import { Who } from '../server/index.js'; -import { RemotesAuthType } from '../server/Account.js'; export class IoCloneFactory extends CloneFactory { /** @@ -31,32 +29,30 @@ export class IoCloneFactory extends CloneFactory { return IoRemotes; } - async reusableConfig( - config: BaseGatewayConfig, - remotesAuth: RemotesAuthType[], - who?: Who - ): Promise> { - return Env.mergeConfig(super.reusableConfig(config, remotesAuth, who), - await this.ioConfig(config, { remotesAuth, who })); + async reusableConfig(config: BaseGatewayConfig, context: ConfigContext) { + return Env.mergeConfig( + super.reusableConfig(config, context), + await this.ioConfig(config, context) + ); } - private async ioConfig( - config: BaseGatewayConfig, - reusable?: { remotesAuth: RemotesAuthType[], who?: Who } - ) { + private async ioConfig(config: BaseGatewayConfig, context?: ConfigContext) { // Reusable config always doles out public gateway address - const uri = !reusable && this.address ? this.address : + const uri = !context && this.address ? this.address : (await resolveGateway(config.gateway)).toString(); const io: MeldIoConfig['io'] = { uri }; // When using Socket.io, the authorisation key is sent to the server // See https://socket.io/docs/v4/middlewares/#sending-credentials - if (!reusable || !reusable.remotesAuth.length || reusable.remotesAuth.includes('key')) { - const key = reusable ? '≪your-auth-key≫' : config.auth.key; + // Try and resolve the most useful authorisation key possible + if (context?.remotesAuth.includes('jwt')) { + const jwt = await context.mintJwt?.() ?? '≪your-token≫'; + io.opts = { auth: { jwt } } + } else if (!context?.remotesAuth.length || context.remotesAuth.includes('key')) { + // Do not reveal the config key unless this is local + const key = context ? '≪your-auth-key≫' : config.auth.key; // The user may be undefined, if this is a Gateway - const user = reusable ? reusable.who?.acc.name ?? '≪your-account-name≫' : config.user; + const user = context ? context.who?.acc.name ?? '≪your-account-name≫' : config.user; io.opts = { auth: { key, user } }; - } else if (reusable.remotesAuth.includes('jwt')) { - io.opts = { auth: { jwt: '≪your-token≫' } } } return { io }; } diff --git a/src/start.ts b/src/start.ts index 1ea12fc..323946a 100644 --- a/src/start.ts +++ b/src/start.ts @@ -6,6 +6,7 @@ import { as, asLogLevel, AuthKey, CloneFactory, DomainKeyStore, Env, KeyStore, resolveDomain, validate } from './lib/index.js'; import { logNotifier, SmtpNotifier } from './server/Notifier.js'; +import { asRsaKeyConfig } from './data/UserKey.js'; import { uuid } from '@m-ld/m-ld'; import { Liquid } from 'liquidjs'; import { fileURLToPath } from 'url'; @@ -32,11 +33,7 @@ import { fileURLToPath } from 'url'; auth: as.object({ // auth key specified for Gateway key: as.string().required() }).required(), - key: as.object({ // key pair specified for Gateway - type: as.equal('rsa').default('rsa'), - public: as.string().base64().required(), - private: as.string().base64().required() - }).required(), + key: asRsaKeyConfig.keys({ private: as.required() }).required(), address: as.object({ port: as.number().default(3000), host: as.string().optional() diff --git a/test/Gateway.test.ts b/test/Gateway.test.ts index 68f595d..f6e631a 100644 --- a/test/Gateway.test.ts +++ b/test/Gateway.test.ts @@ -105,7 +105,8 @@ describe('Gateway', () => { test('gets subdomain config', async () => { const sd = new Subdomain({ account: 'test', name: 'sd1', useSignatures: true }); - const sdConfig = await gateway.ensureNamedSubdomain(sd, { acc, keyid: 'keyid' }) as any; + const sdConfig = await gateway.ensureNamedSubdomain( + sd, { acc, key: { keyid: 'keyid' } }) as any; expect(sdConfig).toEqual({ '@domain': 'sd1.test.ex.org', genesis: false, @@ -158,7 +159,7 @@ describe('Gateway', () => { const id = gateway.ownedId({ account: 'test', name: 'sd1' }); // The gateway should attempt to clone the subdomain in the get. // (It will fail due to dead remotes, but we don't care.) - cloneFactory.clone.mockResolvedValueOnce([mock(), mock()]) + cloneFactory.clone.mockResolvedValueOnce([mock(), mock()]); await expect(gateway.getSubdomain(id)).rejects.toThrow(); expect(cloneFactory.clone.mock.lastCall).toMatchObject([ { @@ -175,7 +176,7 @@ describe('Gateway', () => { test('removes a subdomain', async () => { const sd = new Subdomain({ account: 'test', name: 'sd1' }); - await gateway.ensureNamedSubdomain(sd, { acc, keyid: 'keyid' }); + await gateway.ensureNamedSubdomain(sd, { acc, key: { keyid: 'keyid' } }); await gateway.domain.write({ '@delete': { '@id': 'test', subdomain: { '@id': 'test/sd1', '?': '?' } } }); @@ -184,7 +185,7 @@ describe('Gateway', () => { expect(!existsSync(join(env.tmpDir.name, 'data', 'domain', 'test', 'sd1'))); expect(gateway.hasClonedSubdomain(gateway.ownedId(sd))).toBe(false); // Cannot re-use a subdomain name - await expect(gateway.ensureNamedSubdomain(sd, { acc, keyid: 'keyid' })) + await expect(gateway.ensureNamedSubdomain(sd, { acc, key: { keyid: 'keyid' } })) .rejects.toThrowError(); }); }); diff --git a/test/IoCloneFactory.test.ts b/test/IoCloneFactory.test.ts index f4948fa..a05d087 100644 --- a/test/IoCloneFactory.test.ts +++ b/test/IoCloneFactory.test.ts @@ -5,13 +5,16 @@ import { mock } from 'jest-mock-extended'; describe('Socket.io clone factory', () => { test('reusable config with key', async () => { const cloneFactory = new IoCloneFactory(); - await cloneFactory.initialise('localhost:8080'); + cloneFactory.initialise('localhost:8080'); await expect(cloneFactory.reusableConfig({ '@id': 'test1', '@domain': 'ex.org', genesis: false, gateway: 'ex.org', auth: { key: 'appid.keyid:secret' } - }, [/*'key' is the default*/], { - acc: mock({ name: 'myAccount' }), - keyid: 'keyid' + }, { + who: { + acc: mock({ name: 'myAccount' }), + key: { keyid: 'keyid' } + }, + remotesAuth: [/*'key' is the default*/] })).resolves.toEqual({ io: { uri: 'https://ex.org/', // Uses public gateway @@ -20,13 +23,15 @@ describe('Socket.io clone factory', () => { }); }); - test('reusable config with jwt', async () => { + test('reusable config with JWT placeholder', async () => { const cloneFactory = new IoCloneFactory(); - await cloneFactory.initialise('localhost:8080'); + cloneFactory.initialise('localhost:8080'); await expect(cloneFactory.reusableConfig({ '@id': 'test1', '@domain': 'ex.org', genesis: false, gateway: 'ex.org', auth: { key: 'appid.keyid:secret' } - }, ['jwt'])).resolves.toEqual({ + }, { + remotesAuth: ['jwt'] + })).resolves.toEqual({ io: { uri: 'https://ex.org/', // Uses public gateway opts: { auth: { jwt: '≪your-token≫' } } @@ -34,13 +39,32 @@ describe('Socket.io clone factory', () => { }); }); + test('reusable config with minted JWT', async () => { + const cloneFactory = new IoCloneFactory(); + cloneFactory.initialise('localhost:8080'); + await expect(cloneFactory.reusableConfig({ + '@id': 'test1', '@domain': 'ex.org', genesis: false, + gateway: 'ex.org', auth: { key: 'appid.keyid:secret' } + }, { + remotesAuth: ['jwt'], + async mintJwt() { return 'myJwt' } + })).resolves.toEqual({ + io: { + uri: 'https://ex.org/', // Uses public gateway + opts: { auth: { jwt: 'myJwt' } } + } + }); + }); + test('reusable config with anon', async () => { const cloneFactory = new IoCloneFactory(); - await cloneFactory.initialise('localhost:8080'); + cloneFactory.initialise('localhost:8080'); await expect(cloneFactory.reusableConfig({ '@id': 'test1', '@domain': 'ex.org', genesis: false, gateway: 'ex.org', auth: { key: 'appid.keyid:secret' } - }, ['anon'])).resolves.toEqual({ + }, { + remotesAuth: ['anon'] + })).resolves.toEqual({ io: { uri: 'https://ex.org/' } }); }); diff --git a/test/IoService.test.ts b/test/IoService.test.ts index 43ffcee..e26ecaa 100644 --- a/test/IoService.test.ts +++ b/test/IoService.test.ts @@ -99,7 +99,7 @@ describe('Socket.io service', () => { test('cannot connect to named subdomain anonymously', async () => { const subdomain = { account: acc.name, name: 'flintstones' }; const clientConfig = Env.mergeConfig( - await gateway.ensureNamedSubdomain(subdomain, { acc, keyid: key.keyid }), + await gateway.ensureNamedSubdomain(subdomain, { acc, key: { keyid: 'keyid' } }), { '@id': uuid(), io: { opts: false } } // Remove auth placeholders ); await expect(clone(new MemoryLevel, IoRemotes, clientConfig)).rejects.toThrow(); @@ -107,7 +107,8 @@ describe('Socket.io service', () => { test('can connect to subdomain with user account key', async () => { const subdomain = { account: acc.name, name: 'flintstones' }; - const config = await gateway.ensureNamedSubdomain(subdomain, { acc, keyid: key.keyid }); + const config = await gateway.ensureNamedSubdomain( + subdomain, { acc, key: { keyid: 'keyid' } }); const gwClone = (await gateway.getSubdomain(gateway.ownedId(subdomain)))!; await gwClone.write({ '@id': 'fred', name: 'Fred' }); await gwClone.unlock(); @@ -124,7 +125,8 @@ describe('Socket.io service', () => { test('can connect to a domain cleared from the cache', async () => { const subdomain = { account: acc.name, name: 'flintstones' }; - const config = await gateway.ensureNamedSubdomain(subdomain, { acc, keyid: key.keyid }); + const config = await gateway.ensureNamedSubdomain( + subdomain, { acc, key: { keyid: 'keyid' } }); await subdomainCache.clear(); expect(subdomainCache.has(gateway.ownedId(subdomain).toDomain())).toBe(false); const clientConfig = Env.mergeConfig(config, { diff --git a/test/http.test.ts b/test/http.test.ts index f7beb0d..560a953 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -6,10 +6,11 @@ import { parseNdJson, TestCloneFactory, testCloneFactory, TestEnv } from './fixt import { Account, Gateway, Notifier, SubdomainCache } from '../src/server/index.js'; import { setupGatewayHttp } from '../src/http/index.js'; import request from 'supertest'; -import { mock, MockProxy } from 'jest-mock-extended'; +import { anyString, mock, MockProxy } from 'jest-mock-extended'; import { Server } from 'restify'; import { Readable } from 'stream'; import type { Liquid } from 'liquidjs'; +import { decode } from 'jsonwebtoken'; describe('Gateway HTTP API', () => { let env: TestEnv; @@ -91,6 +92,13 @@ describe('Gateway HTTP API', () => { }); }); + test('gets root public key', async () => { + const res = await request(app) + .get('/api/v1/publicKey'); + expect(res.status).toBe(200); + expect(res.text).toMatch(/-+BEGIN PUBLIC KEY-+\s[-A-Za-z0-9+\n=/]*/); + }); + test('root create new account with a key', async () => { const res = await request(app) .post('/api/v1/user/test/key?type=rsa') @@ -154,6 +162,14 @@ describe('Gateway HTTP API', () => { }); }); + test('gets user public key', async () => { + const res = await request(app) + .get('/api/v1/user/test/publicKey/keyid') + .auth('test', 'app.keyid:secret'); + expect(res.status).toBe(200); + expect(res.text).toMatch(/-+BEGIN PUBLIC KEY-+\s[-A-Za-z0-9+\n=/]*/); + }); + test('cannot post new uuid subdomain without config', async () => { const res = await request(app) .post('/api/v1/domain/test') @@ -235,6 +251,25 @@ describe('Gateway HTTP API', () => { }); }); + test('puts new subdomain with JWT', async () => { + await acc.update({ '@insert': { remotesAuth: 'jwt' } }); + cloneFactory.reusableConfig.mockImplementation(async (_config, context) => ({ + jwt: await context?.mintJwt?.() + })); + const res = await request(app) + .put('/api/v1/domain/test/sd1') + .auth('test', 'app.keyid:secret') + .accept('application/json') + .send({ user: { '@id': 'http://ex.org/fred' } }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + '@domain': 'sd1.test.ex.org', genesis: false, jwt: anyString() + }); + expect(decode(res.body.jwt, { json: true })).toMatchObject({ + iss: 'http://ex.org/test', sub: 'http://ex.org/fred' + }); + }); + test.todo('Put subdomain with context'); describe('with subdomain', () => { @@ -243,7 +278,7 @@ describe('Gateway HTTP API', () => { beforeEach(async () => { sdId = gateway.ownedId({ account: 'test', name: 'sd1' }); - await gateway.ensureNamedSubdomain(sdId, { acc, keyid: 'keyid' }); + await gateway.ensureNamedSubdomain(sdId, { acc, key: { keyid: 'keyid' } }); clone = cloneFactory.clones[sdId.toDomain()]; }); diff --git a/tsconfig.json b/tsconfig.json index 6370b5f..4886576 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "noImplicitAny": true, - "target": "ES2020", + "target": "ES2021", "module": "ES2020", "moduleResolution": "node", "inlineSourceMap": true,