Add comprehensive installation and setup documentation

- Add GETTING_STARTED.md with quick start guide and development modes
- Add INSTALL.sh automated installation script
- Add INSTALLATION_CHECKLIST.md, INSTALLATION_SUCCESS.md, and INSTALLATION_SUMMARY.md
- Add QUICK_REFERENCE.md for common commands
- Add SETUP_GUIDE.md with detailed setup instructions
- Update README.md with improved project overview
- Add did-wallet app dependencies and node_modules
This commit is contained in:
Dorian
2026-01-27 17:18:21 +00:00
parent a81f655133
commit 0d073fa89e
22658 changed files with 4494151 additions and 6 deletions
@@ -0,0 +1,55 @@
import type { MessageInterface } from '../types/message-interface.js';
import type { GenericMessage, GenericSignaturePayload } from '../types/message-types.js';
import { Jws } from '../utils/jws.js';
import { Message } from './message.js';
/**
* An abstract implementation of the `MessageInterface` interface.
*/
export abstract class AbstractMessage<M extends GenericMessage> implements MessageInterface<M> {
private _message: M;
public get message(): M {
return this._message as M;
}
private _signer: string | undefined;
public get signer(): string | undefined {
return this._signer;
}
private _author: string | undefined;
public get author(): string | undefined {
return this._author;
}
private _signaturePayload: GenericSignaturePayload | undefined;
public get signaturePayload(): GenericSignaturePayload | undefined {
return this._signaturePayload;
}
protected constructor(message: M) {
this._message = message;
if (message.authorization !== undefined) {
this._signer = Message.getSigner(message);
// if the message authorization contains author delegated grant, the author would be the grantor of the grant
// else the author would be the signer of the message
if (message.authorization.authorDelegatedGrant !== undefined) {
this._author = Message.getSigner(message.authorization.authorDelegatedGrant);
} else {
this._author = this._signer;
}
this._signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature);
}
}
/**
* Called by `JSON.stringify(...)` automatically.
*/
toJSON(): GenericMessage {
return this.message;
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { DidResolver } from '@web5/dids';
import type { MessageInterface } from '../types/message-interface.js';
import type { AuthorizationModel, GenericMessage } from '../types/message-types.js';
import { GeneralJwsVerifier } from '../jose/jws/general/verifier.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
/**
* Verifies all the signature(s) within the authorization property.
*
* @throws {Error} if fails authentication
*/
export async function authenticate(authorizationModel: AuthorizationModel | undefined, didResolver: DidResolver): Promise<void> {
if (authorizationModel === undefined) {
throw new DwnError(DwnErrorCode.AuthenticateJwsMissing, 'Missing JWS.');
}
await GeneralJwsVerifier.verifySignatures(authorizationModel.signature, didResolver);
if (authorizationModel.ownerSignature !== undefined) {
await GeneralJwsVerifier.verifySignatures(authorizationModel.ownerSignature, didResolver);
}
if (authorizationModel.authorDelegatedGrant !== undefined) {
// verify the signature of the grantor of the author-delegated grant
const authorDelegatedGrant = await RecordsWrite.parse(authorizationModel.authorDelegatedGrant);
await GeneralJwsVerifier.verifySignatures(authorDelegatedGrant.message.authorization.signature, didResolver);
}
if (authorizationModel.ownerDelegatedGrant !== undefined) {
// verify the signature of the grantor of the owner-delegated grant
const ownerDelegatedGrant = await RecordsWrite.parse(authorizationModel.ownerDelegatedGrant);
await GeneralJwsVerifier.verifySignatures(ownerDelegatedGrant.message.authorization.signature, didResolver);
}
}
/**
* Authorizes owner authored message.
* @throws {DwnError} if fails authorization.
*/
export async function authorizeOwner(tenant: string, incomingMessage: MessageInterface<GenericMessage>): Promise<void> {
// if author is the same as the target tenant, we can directly grant access
if (incomingMessage.author === tenant) {
return;
} else {
throw new DwnError(
DwnErrorCode.AuthorizationAuthorNotOwner,
`Message authored by ${incomingMessage.author}, not authored by expected owner ${tenant}.`
);
}
}
@@ -0,0 +1,9 @@
export class DwnConstant {
/**
* The maximum size of raw data that will be returned as `encodedData`.
*
* We chose 30k, as after encoding it would give plenty of headroom up to the 65k limit in most SQL variants.
* We currently encode using base64url which is a 33% increase in size.
*/
public static readonly maxDataSizeAllowedToBeEncoded = 30_000;
}
@@ -0,0 +1,162 @@
/**
* A class that represents a DWN error.
*/
export class DwnError extends Error {
constructor (public code: string, message: string) {
super(`${code}: ${message}`);
this.name = 'DwnError';
}
}
/**
* DWN SDK error codes.
*/
export enum DwnErrorCode {
AuthenticateJwsMissing = 'AuthenticateJwsMissing',
AuthenticateDescriptorCidMismatch = 'AuthenticateDescriptorCidMismatch',
AuthenticationMoreThanOneSignatureNotSupported = 'AuthenticationMoreThanOneSignatureNotSupported',
AuthorizationAuthorNotOwner = 'AuthorizationAuthorNotOwner',
AuthorizationNotGrantedToAuthor = 'AuthorizationNotGrantedToAuthor',
ComputeCidCodecNotSupported = 'ComputeCidCodecNotSupported',
ComputeCidMultihashNotSupported = 'ComputeCidMultihashNotSupported',
DidMethodNotSupported = 'DidMethodNotSupported',
DidNotString = 'DidNotString',
DidNotValid = 'DidNotValid',
DidResolutionFailed = 'DidResolutionFailed',
Ed25519InvalidJwk = 'Ed25519InvalidJwk',
EventEmitterStreamNotOpenError = 'EventEmitterStreamNotOpenError',
EventsSubscribeEventStreamUnimplemented = 'EventsSubscribeEventStreamUnimplemented',
GeneralJwsVerifierGetPublicKeyNotFound = 'GeneralJwsVerifierGetPublicKeyNotFound',
GeneralJwsVerifierInvalidSignature = 'GeneralJwsVerifierInvalidSignature',
GrantAuthorizationGrantExpired = 'GrantAuthorizationGrantExpired',
GrantAuthorizationGrantMissing = 'GrantAuthorizationGrantMissing',
GrantAuthorizationGrantRevoked = 'GrantAuthorizationGrantRevoked',
GrantAuthorizationInterfaceMismatch = 'GrantAuthorizationInterfaceMismatch',
GrantAuthorizationMethodMismatch = 'GrantAuthorizationMethodMismatch',
GrantAuthorizationNotGrantedForTenant = 'GrantAuthorizationNotGrantedForTenant',
GrantAuthorizationNotGrantedToAuthor = 'GrantAuthorizationNotGrantedToAuthor',
GrantAuthorizationGrantNotYetActive = 'GrantAuthorizationGrantNotYetActive',
HdKeyDerivationPathInvalid = 'HdKeyDerivationPathInvalid',
JwsVerifySignatureUnsupportedCrv = 'JwsVerifySignatureUnsupportedCrv',
IndexInvalidCursorValueType = 'IndexInvalidCursorValueType',
IndexInvalidCursorSortProperty = 'IndexInvalidCursorSortProperty',
IndexInvalidSortPropertyInMemory = 'IndexInvalidSortPropertyInMemory',
IndexMissingIndexableProperty = 'IndexMissingIndexableProperty',
JwsDecodePlainObjectPayloadInvalid = 'JwsDecodePlainObjectPayloadInvalid',
MessageGetInvalidCid = 'MessageGetInvalidCid',
ParseCidCodecNotSupported = 'ParseCidCodecNotSupported',
ParseCidMultihashNotSupported = 'ParseCidMultihashNotSupported',
PermissionsProtocolValidateSchemaUnexpectedRecord = 'PermissionsProtocolValidateSchemaUnexpectedRecord',
PermissionsProtocolValidateScopeContextIdProhibitedProperties = 'PermissionsProtocolValidateScopeContextIdProhibitedProperties',
PermissionsProtocolValidateScopeSchemaProhibitedProperties = 'PermissionsProtocolValidateScopeSchemaProhibitedProperties',
PrivateKeySignerUnableToDeduceAlgorithm = 'PrivateKeySignerUnableToDeduceAlgorithm',
PrivateKeySignerUnableToDeduceKeyId = 'PrivateKeySignerUnableToDeduceKeyId',
PrivateKeySignerUnsupportedCurve = 'PrivateKeySignerUnsupportedCurve',
ProtocolAuthorizationActionNotAllowed = 'ProtocolAuthorizationActionNotAllowed',
ProtocolAuthorizationActionRulesNotFound = 'ProtocolAuthorizationActionRulesNotFound',
ProtocolAuthorizationIncorrectDataFormat = 'ProtocolAuthorizationIncorrectDataFormat',
ProtocolAuthorizationIncorrectContextId = 'ProtocolAuthorizationIncorrectContextId',
ProtocolAuthorizationIncorrectProtocolPath = 'ProtocolAuthorizationIncorrectProtocolPath',
ProtocolAuthorizationDuplicateRoleRecipient = 'ProtocolAuthorizationDuplicateRoleRecipient',
ProtocolAuthorizationInvalidSchema = 'ProtocolAuthorizationInvalidSchema',
ProtocolAuthorizationInvalidType = 'ProtocolAuthorizationInvalidType',
ProtocolAuthorizationMatchingRoleRecordNotFound = 'ProtocolAuthorizationMatchingRoleRecordNotFound',
ProtocolAuthorizationMaxSizeInvalid = 'ProtocolAuthorizationMaxSizeInvalid',
ProtocolAuthorizationMinSizeInvalid = 'ProtocolAuthorizationMinSizeInvalid',
ProtocolAuthorizationMissingContextId = 'ProtocolAuthorizationMissingContextId',
ProtocolAuthorizationMissingRuleSet = 'ProtocolAuthorizationMissingRuleSet',
ProtocolAuthorizationParentlessIncorrectProtocolPath = 'ProtocolAuthorizationParentlessIncorrectProtocolPath',
ProtocolAuthorizationNotARole = 'ProtocolAuthorizationNotARole',
ProtocolAuthorizationParentNotFoundConstructingRecordChain = 'ProtocolAuthorizationParentNotFoundConstructingRecordChain',
ProtocolAuthorizationProtocolNotFound = 'ProtocolAuthorizationProtocolNotFound',
ProtocolAuthorizationQueryWithoutRole = 'ProtocolAuthorizationQueryWithoutRole',
ProtocolAuthorizationRoleMissingRecipient = 'ProtocolAuthorizationRoleMissingRecipient',
ProtocolAuthorizationTagsInvalidSchema = 'ProtocolAuthorizationTagsInvalidSchema',
ProtocolsConfigureDuplicateActorInRuleSet = 'ProtocolsConfigureDuplicateActorInRuleSet',
ProtocolsConfigureDuplicateRoleInRuleSet = 'ProtocolsConfigureDuplicateRoleInRuleSet',
ProtocolsConfigureInvalidSize = 'ProtocolsConfigureInvalidSize',
ProtocolsConfigureInvalidActionMissingOf = 'ProtocolsConfigureInvalidActionMissingOf',
ProtocolsConfigureInvalidActionOfNotAllowed = 'ProtocolsConfigureInvalidActionOfNotAllowed',
ProtocolsConfigureInvalidActionDeleteWithoutCreate = 'ProtocolsConfigureInvalidActionDeleteWithoutCreate',
ProtocolsConfigureInvalidActionUpdateWithoutCreate = 'ProtocolsConfigureInvalidActionUpdateWithoutCreate',
ProtocolsConfigureInvalidRecipientOfAction = 'ProtocolsConfigureInvalidRecipientOfAction',
ProtocolsConfigureInvalidRuleSetRecordType = 'ProtocolsConfigureInvalidRuleSetRecordType',
ProtocolsConfigureInvalidTagSchema = 'ProtocolsConfigureInvalidTagSchema',
ProtocolsConfigureQueryNotAllowed = 'ProtocolsConfigureQueryNotAllowed',
ProtocolsConfigureRecordNestingDepthExceeded = 'ProtocolsConfigureRecordNestingDepthExceeded',
ProtocolsConfigureRoleDoesNotExistAtGivenPath = 'ProtocolsConfigureRoleDoesNotExistAtGivenPath',
ProtocolsConfigureUnauthorized = 'ProtocolsConfigureUnauthorized',
ProtocolsQueryUnauthorized = 'ProtocolsQueryUnauthorized',
RecordsAuthorDelegatedGrantAndIdExistenceMismatch = 'RecordsAuthorDelegatedGrantAndIdExistenceMismatch',
RecordsAuthorDelegatedGrantCidMismatch = 'RecordsAuthorDelegatedGrantCidMismatch',
RecordsAuthorDelegatedGrantGrantedToAndOwnerSignatureMismatch = 'RecordsAuthorDelegatedGrantGrantedToAndOwnerSignatureMismatch',
RecordsAuthorDelegatedGrantNotADelegatedGrant = 'RecordsAuthorDelegatedGrantNotADelegatedGrant',
RecordsDecryptNoMatchingKeyEncryptedFound = 'RecordsDecryptNoMatchingKeyEncryptedFound',
RecordsDeleteAuthorizationFailed = 'RecordsDeleteAuthorizationFailed',
RecordsQueryCreateFilterPublishedSortInvalid = 'RecordsQueryCreateFilterPublishedSortInvalid',
RecordsQueryParseFilterPublishedSortInvalid = 'RecordsQueryParseFilterPublishedSortInvalid',
RecordsGrantAuthorizationConditionPublicationProhibited = 'RecordsGrantAuthorizationConditionPublicationProhibited',
RecordsGrantAuthorizationConditionPublicationRequired = 'RecordsGrantAuthorizationConditionPublicationRequired',
RecordsGrantAuthorizationDeleteProtocolScopeMismatch = 'RecordsGrantAuthorizationDeleteProtocolScopeMismatch',
RecordsGrantAuthorizationQueryOrSubscribeProtocolScopeMismatch = 'RecordsGrantAuthorizationQueryOrSubscribeProtocolScopeMismatch',
RecordsGrantAuthorizationScopeContextIdMismatch = 'RecordsGrantAuthorizationScopeContextIdMismatch',
RecordsGrantAuthorizationScopeMissingProtocol = 'RecordsGrantAuthorizationScopeMissingProtocol',
RecordsGrantAuthorizationScopeNotRecords = `RecordsGrantAuthorizationScopeNotRecords`,
RecordsGrantAuthorizationScopeProtocolMismatch = 'RecordsGrantAuthorizationScopeProtocolMismatch',
RecordsGrantAuthorizationScopeProtocolPathMismatch = 'RecordsGrantAuthorizationScopeProtocolPathMismatch',
RecordsGrantAuthorizationScopeSchema = 'RecordsGrantAuthorizationScopeSchema',
RecordsDerivePrivateKeyUnSupportedCurve = 'RecordsDerivePrivateKeyUnSupportedCurve',
RecordsInvalidAncestorKeyDerivationSegment = 'RecordsInvalidAncestorKeyDerivationSegment',
RecordsOwnerDelegatedGrantAndIdExistenceMismatch = 'RecordsOwnerDelegatedGrantAndIdExistenceMismatch',
RecordsOwnerDelegatedGrantCidMismatch = 'RecordsOwnerDelegatedGrantCidMismatch',
RecordsOwnerDelegatedGrantGrantedToAndOwnerSignatureMismatch = 'RecordsOwnerDelegatedGrantGrantedToAndOwnerSignatureMismatch',
RecordsOwnerDelegatedGrantNotADelegatedGrant = 'RecordsOwnerDelegatedGrantNotADelegatedGrant',
RecordsProtocolContextDerivationSchemeMissingContextId = 'RecordsProtocolContextDerivationSchemeMissingContextId',
RecordsProtocolPathDerivationSchemeMissingProtocol = 'RecordsProtocolPathDerivationSchemeMissingProtocol',
RecordsQueryFilterMissingRequiredProperties = 'RecordsQueryFilterMissingRequiredProperties',
RecordsReadReturnedMultiple = 'RecordsReadReturnedMultiple',
RecordsReadAuthorizationFailed = 'RecordsReadAuthorizationFailed',
RecordsSubscribeEventStreamUnimplemented = 'RecordsSubscribeEventStreamUnimplemented',
RecordsSubscribeFilterMissingRequiredProperties = 'RecordsSubscribeFilterMissingRequiredProperties',
RecordsSchemasDerivationSchemeMissingSchema = 'RecordsSchemasDerivationSchemeMissingSchema',
RecordsWriteAttestationIntegrityMoreThanOneSignature = 'RecordsWriteAttestationIntegrityMoreThanOneSignature',
RecordsWriteAttestationIntegrityDescriptorCidMismatch = 'RecordsWriteAttestationIntegrityDescriptorCidMismatch',
RecordsWriteAttestationIntegrityInvalidPayloadProperty = 'RecordsWriteAttestationIntegrityInvalidPayloadProperty',
RecordsWriteAuthorizationFailed = 'RecordsWriteAuthorizationFailed',
RecordsWriteCreateMissingSigner = 'RecordsWriteCreateMissingSigner',
RecordsWriteCreateDataAndDataCidMutuallyExclusive = 'RecordsWriteCreateDataAndDataCidMutuallyExclusive',
RecordsWriteCreateDataCidAndDataSizeMutuallyInclusive = 'RecordsWriteCreateDataCidAndDataSizeMutuallyInclusive',
RecordsWriteCreateProtocolAndProtocolPathMutuallyInclusive = 'RecordsWriteCreateProtocolAndProtocolPathMutuallyInclusive',
RecordsWriteDataCidMismatch = 'RecordsWriteDataCidMismatch',
RecordsWriteDataSizeMismatch = 'RecordsWriteDataSizeMismatch',
RecordsWriteGetEntryIdUndefinedAuthor = 'RecordsWriteGetEntryIdUndefinedAuthor',
RecordsWriteGetInitialWriteNotFound = 'RecordsWriteGetInitialWriteNotFound',
RecordsWriteImmutablePropertyChanged = 'RecordsWriteImmutablePropertyChanged',
RecordsWriteMissingSigner = 'RecordsWriteMissingSigner',
RecordsWriteMissingDataInPrevious = 'RecordsWriteMissingDataInPrevious',
RecordsWriteMissingEncodedDataInPrevious = 'RecordsWriteMissingEncodedDataInPrevious',
RecordsWriteMissingDataStream = 'RecordsWriteMissingDataStream',
RecordsWriteMissingProtocol = 'RecordsWriteMissingProtocol',
RecordsWriteMissingSchema = 'RecordsWriteMissingSchema',
RecordsWriteOwnerAndTenantMismatch = 'RecordsWriteOwnerAndTenantMismatch',
RecordsWriteSignAsOwnerDelegateUnknownAuthor = 'RecordsWriteSignAsOwnerDelegateUnknownAuthor',
RecordsWriteSignAsOwnerUnknownAuthor = 'RecordsWriteSignAsOwnerUnknownAuthor',
RecordsWriteValidateIntegrityAttestationMismatch = 'RecordsWriteValidateIntegrityAttestationMismatch',
RecordsWriteValidateIntegrityContextIdMismatch = 'RecordsWriteValidateIntegrityContextIdMismatch',
RecordsWriteValidateIntegrityContextIdNotInSignerSignaturePayload = 'RecordsWriteValidateIntegrityContextIdNotInSignerSignaturePayload',
RecordsWriteValidateIntegrityDateCreatedMismatch = 'RecordsWriteValidateIntegrityDateCreatedMismatch',
RecordsWriteValidateIntegrityEncryptionCidMismatch = 'RecordsWriteValidateIntegrityEncryptionCidMismatch',
RecordsWriteValidateIntegrityRecordIdUnauthorized = 'RecordsWriteValidateIntegrityRecordIdUnauthorized',
SchemaValidatorAdditionalPropertyNotAllowed = 'SchemaValidatorAdditionalPropertyNotAllowed',
SchemaValidatorFailure = 'SchemaValidatorFailure',
SchemaValidatorSchemaNotFound = 'SchemaValidatorSchemaNotFound',
SchemaValidatorUnevaluatedPropertyNotAllowed = 'SchemaValidatorUnevaluatedPropertyNotAllowed',
Secp256k1KeyNotValid = 'Secp256k1KeyNotValid',
Secp256r1KeyNotValid = 'Secp256r1KeyNotValid',
TimestampInvalid = 'TimestampInvalid',
UrlProtocolNotNormalized = 'UrlProtocolNotNormalized',
UrlProtocolNotNormalizable = 'UrlProtocolNotNormalizable',
UrlSchemaNotNormalized = 'UrlSchemaNotNormalized',
UrlSchemaNotNormalizable = 'UrlSchemaNotNormalizable',
};
@@ -0,0 +1,148 @@
import type { GenericMessage } from '../types/message-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { PermissionGrant } from '../protocols/permission-grant.js';
import { Message } from './message.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
export class GrantAuthorization {
/**
* Performs base permissions-grant-based authorization against the given message:
* 1. Validates the `expectedGrantor` and `expectedGrantee` values against the actual values in given permission grant.
* 2. Verifies that the incoming message is within the allowed time frame of the grant, and the grant has not been revoked.
* 3. Verifies that the `interface` and `method` grant scopes match the incoming message.
*
* NOTE: Does not validate grant `conditions` or `scope` beyond `interface` and `method`
*
* @param messageStore Used to check if the grant has been revoked.
* @throws {DwnError} if validation fails
*/
public static async performBaseValidation(input: {
incomingMessage: GenericMessage,
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
}): Promise<void> {
const { incomingMessage, expectedGrantor, expectedGrantee, permissionGrant, messageStore } = input;
const incomingMessageDescriptor = incomingMessage.descriptor;
GrantAuthorization.verifyExpectedGrantorAndGrantee(expectedGrantor, expectedGrantee, permissionGrant);
// verify that grant is active during incomingMessage's timestamp
const grantedFor = expectedGrantor; // renaming for better readability now that we have verified the grantor above
await GrantAuthorization.verifyGrantActive(
grantedFor,
incomingMessageDescriptor.messageTimestamp,
permissionGrant,
messageStore
);
// Check grant scope for interface and method
await GrantAuthorization.verifyGrantScopeInterfaceAndMethod(
incomingMessageDescriptor.interface,
incomingMessageDescriptor.method,
permissionGrant,
);
}
/**
* Verifies the given `expectedGrantor` and `expectedGrantee` values against
* the actual signer and recipient in given permission grant.
* @throws {DwnError} if `expectedGrantor` or `expectedGrantee` do not match the actual values in the grant.
*/
private static verifyExpectedGrantorAndGrantee(
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant
): void {
const actualGrantee = permissionGrant.grantee;
if (expectedGrantee !== actualGrantee) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationNotGrantedToAuthor,
`Permission grant is granted to ${actualGrantee}, but need to be granted to ${expectedGrantee}`
);
}
const actualGrantor = permissionGrant.grantor;
if (expectedGrantor !== actualGrantor) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationNotGrantedForTenant,
`Permission grant is granted by ${actualGrantor}, but need to be granted by ${expectedGrantor}`
);
}
}
/**
* Verify that the incoming message is within the allowed time frame of the grant,
* and the grant has not been revoked.
* @param messageStore Used to check if the grant has been revoked.
* @throws {DwnError} if incomingMessage has timestamp for a time in which the grant is not active.
*/
private static async verifyGrantActive(
grantedFor: string,
incomingMessageTimestamp: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
): Promise<void> {
// Check that incomingMessage is within the grant's time frame
if (incomingMessageTimestamp < permissionGrant.dateGranted) {
// grant is not yet active
throw new DwnError(
DwnErrorCode.GrantAuthorizationGrantNotYetActive,
`The message has a timestamp before the associated permission grant becomes active`,
);
}
if (incomingMessageTimestamp >= permissionGrant.dateExpires) {
// grant has expired
throw new DwnError(
DwnErrorCode.GrantAuthorizationGrantExpired,
`The message has timestamp after the expiry of the associated permission grant`,
);
}
// Check if grant has been revoked
const query = {
parentId : permissionGrant.id,
protocolPath : `grant/revocation`, // NOTE: this is optional, not referencing PermissionsProtocol.revocationPath due to circular dependency
isLatestBaseState : true
};
const { messages: revokes } = await messageStore.query(grantedFor, [query]);
const oldestExistingRevoke = await Message.getOldestMessage(revokes);
if (oldestExistingRevoke !== undefined && oldestExistingRevoke.descriptor.messageTimestamp <= incomingMessageTimestamp) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationGrantRevoked,
`Permission grant with CID ${permissionGrant.id} has been revoked`,
);
}
}
/**
* Verify that the `interface` and `method` grant scopes match the incoming message
* @param permissionGrantId Purely being passed for logging purposes.
* @throws {DwnError} if the `interface` and `method` of the incoming message do not match the scope of the permission grant.
*/
private static async verifyGrantScopeInterfaceAndMethod(
dwnInterface: string,
dwnMethod: string,
permissionGrant: PermissionGrant,
): Promise<void> {
if (dwnInterface !== permissionGrant.scope.interface) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationInterfaceMismatch,
`DWN Interface of incoming message is outside the scope of permission grant with ID ${permissionGrant.id}`
);
} else if (dwnMethod !== permissionGrant.scope.method) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationMethodMismatch,
`DWN Method of incoming message is outside the scope of permission grant with ID ${permissionGrant.id}`
);
}
}
}
@@ -0,0 +1,48 @@
import type { MessagesGetReplyEntry } from '../types/messages-types.js';
import type { PaginationCursor } from '../types/query-types.js';
import type { ProtocolsConfigureMessage } from '../types/protocols-types.js';
import type { Readable } from 'readable-stream';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { GenericMessageReply, MessageSubscription, QueryResultEntry } from '../types/message-types.js';
export function messageReplyFromError(e: unknown, code: number): GenericMessageReply {
const detail = e instanceof Error ? e.message : 'Error';
return { status: { code, detail } };
}
/**
* Catch-all message reply type. It is recommended to use GenericMessageReply or a message-specific reply type wherever possible.
*/
export type UnionMessageReply = GenericMessageReply & {
/**
* Resulting message entries or events returned from the invocation of the corresponding message.
* e.g. the resulting messages from a RecordsQuery, or array of messageCid strings for EventsGet or EventsQuery
* Mutually exclusive with `record`.
*/
entries?: QueryResultEntry[] | ProtocolsConfigureMessage[] | MessagesGetReplyEntry[] | string[];
/**
* Record corresponding to the message received if applicable (e.g. RecordsRead).
* Mutually exclusive with `entries` and `cursor`.
*/
record?: RecordsWriteMessage & {
/**
* The initial write of the record if the returned RecordsWrite message itself is not the initial write.
*/
initialWrite?: RecordsWriteMessage;
data: Readable;
};
/**
* A cursor for pagination if applicable (e.g. RecordsQuery).
* Mutually exclusive with `record`.
*/
cursor?: PaginationCursor;
/**
* A subscription object if a subscription was requested.
*/
subscription?: MessageSubscription;
};
@@ -0,0 +1,240 @@
import type { GeneralJws } from '../types/jws-types.js';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { Signer } from '../types/signer.js';
import type { AuthorizationModel, Descriptor, GenericMessage, GenericSignaturePayload } from '../types/message-types.js';
import { Cid } from '../utils/cid.js';
import { Encoder } from '../utils/encoder.js';
import { GeneralJwsBuilder } from '../jose/jws/general/builder.js';
import { Jws } from '../utils/jws.js';
import { lexicographicalCompare } from '../utils/string.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { validateJsonSchema } from '../schema-validator.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
/**
* A class containing utility methods for working with DWN messages.
*/
export class Message {
/**
* Validates the given message against the corresponding JSON schema.
* @throws {Error} if fails validation.
*/
public static validateJsonSchema(rawMessage: any): void {
const dwnInterface = rawMessage.descriptor.interface;
const dwnMethod = rawMessage.descriptor.method;
const schemaLookupKey = dwnInterface + dwnMethod;
// throws an error if message is invalid
validateJsonSchema(schemaLookupKey, rawMessage);
};
/**
* Gets the DID of the signer of the given message, returns `undefined` if message is not signed.
*/
public static getSigner(message: GenericMessage): string | undefined {
if (message.authorization === undefined) {
return undefined;
}
const signer = Jws.getSignerDid(message.authorization.signature.signatures[0]);
return signer;
}
/**
* Gets the CID of the given message.
*/
public static async getCid(message: GenericMessage): Promise<string> {
// NOTE: we wrap the `computeCid()` here in case that
// the message will contain properties that should not be part of the CID computation
// and we need to strip them out (like `encodedData` that we historically had for a long time),
// but we can remove this method entirely if the code becomes stable and it is apparent that the wrapper is not needed
// ^--- seems like we might need to keep this around for now.
const rawMessage = { ...message } as any;
if (rawMessage.encodedData) {
delete rawMessage.encodedData;
}
const cid = await Cid.computeCid(rawMessage as GenericMessage);
return cid;
}
/**
* Compares message CID in lexicographical order according to the spec.
* @returns 1 if `a` is larger than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same message)
*/
public static async compareCid(a: GenericMessage, b: GenericMessage): Promise<number> {
// the < and > operators compare strings in lexicographical order
const cidA = await Message.getCid(a);
const cidB = await Message.getCid(b);
return lexicographicalCompare(cidA, cidB);
}
/**
* Creates the `authorization` property to be included in a DWN message.
* @param signer Message signer.
* @returns {AuthorizationModel} used as an `authorization` property.
*/
public static async createAuthorization(input: {
descriptor: Descriptor,
signer: Signer,
delegatedGrant?: RecordsWriteMessage,
permissionGrantId?: string,
protocolRole?: string
}): Promise<AuthorizationModel> {
const { descriptor, signer, delegatedGrant, permissionGrantId, protocolRole } = input;
let delegatedGrantId;
if (delegatedGrant !== undefined) {
delegatedGrantId = await Message.getCid(delegatedGrant);
}
const signature = await Message.createSignature(descriptor, signer, { delegatedGrantId, permissionGrantId, protocolRole });
const authorization: AuthorizationModel = {
signature
};
if (delegatedGrant !== undefined) {
authorization.authorDelegatedGrant = delegatedGrant;
}
return authorization;
}
/**
* Creates a generic signature from the given DWN message descriptor by including `descriptorCid` as the required property in the signature payload.
* NOTE: there is an opportunity to consolidate RecordsWrite.createSignerSignature() wth this method
*/
public static async createSignature(
descriptor: Descriptor,
signer: Signer,
additionalPayloadProperties?: { delegatedGrantId?: string, permissionGrantId?: string, protocolRole?: string }
): Promise<GeneralJws> {
const descriptorCid = await Cid.computeCid(descriptor);
const signaturePayload: GenericSignaturePayload = { descriptorCid, ...additionalPayloadProperties };
removeUndefinedProperties(signaturePayload);
const signaturePayloadBytes = Encoder.objectToBytes(signaturePayload);
const builder = await GeneralJwsBuilder.create(signaturePayloadBytes, [signer]);
const signature = builder.getJws();
return signature;
}
/**
* @returns newest message in the array. `undefined` if given array is empty.
*/
public static async getNewestMessage(messages: GenericMessage[]): Promise<GenericMessage | undefined> {
let currentNewestMessage: GenericMessage | undefined = undefined;
for (const message of messages) {
if (currentNewestMessage === undefined || await Message.isNewer(message, currentNewestMessage)) {
currentNewestMessage = message;
}
}
return currentNewestMessage;
}
/**
* @returns oldest message in the array. `undefined` if given array is empty.
*/
public static async getOldestMessage(messages: GenericMessage[]): Promise<GenericMessage | undefined> {
let currentOldestMessage: GenericMessage | undefined = undefined;
for (const message of messages) {
if (currentOldestMessage === undefined || await Message.isOlder(message, currentOldestMessage)) {
currentOldestMessage = message;
}
}
return currentOldestMessage;
}
/**
* Checks if first message is newer than second message.
* @returns `true` if `a` is newer than `b`; `false` otherwise
*/
public static async isNewer(a: GenericMessage, b: GenericMessage): Promise<boolean> {
const aIsNewer = (await Message.compareMessageTimestamp(a, b) > 0);
return aIsNewer;
}
/**
* Checks if first message is older than second message.
* @returns `true` if `a` is older than `b`; `false` otherwise
*/
public static async isOlder(a: GenericMessage, b: GenericMessage): Promise<boolean> {
const aIsOlder = (await Message.compareMessageTimestamp(a, b) < 0);
return aIsOlder;
}
/**
* See if the given message is signed by an author-delegate.
*/
public static isSignedByAuthorDelegate(message: GenericMessage): boolean {
return message.authorization?.authorDelegatedGrant !== undefined;
}
/**
* See if the given message is signed by an owner-delegate.
*/
public static isSignedByOwnerDelegate(message: GenericMessage): boolean {
return message.authorization?.ownerDelegatedGrant !== undefined;
}
/**
* Compares the `messageTimestamp` of the given messages with a fallback to message CID according to the spec.
* @returns 1 if `a` is larger/newer than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same age)
*/
public static async compareMessageTimestamp(a: GenericMessage, b: GenericMessage): Promise<number> {
if (a.descriptor.messageTimestamp > b.descriptor.messageTimestamp) {
return 1;
} else if (a.descriptor.messageTimestamp < b.descriptor.messageTimestamp) {
return -1;
}
// else `messageTimestamp` is the same between a and b
// compare the `dataCid` instead, the < and > operators compare strings in lexicographical order
return Message.compareCid(a, b);
}
/**
* Validates the structural integrity of the message signature given:
* 1. The message signature must contain exactly 1 signature
* 2. Passes JSON schema validation
* 3. The `descriptorCid` property matches the CID of the message descriptor
* NOTE: signature is NOT verified.
* @param payloadJsonSchemaKey The key to look up the JSON schema referenced in `compile-validators.js` and perform payload schema validation on.
* @returns the parsed JSON payload object if validation succeeds.
*/
public static async validateSignatureStructure(
messageSignature: GeneralJws,
messageDescriptor: Descriptor,
payloadJsonSchemaKey: string = 'GenericSignaturePayload',
): Promise<GenericSignaturePayload> {
if (messageSignature.signatures.length !== 1) {
throw new DwnError(DwnErrorCode.AuthenticationMoreThanOneSignatureNotSupported, 'expected no more than 1 signature for authorization purpose');
}
// validate payload integrity
const payloadJson = Jws.decodePlainObjectPayload(messageSignature);
validateJsonSchema(payloadJsonSchemaKey, payloadJson);
// `descriptorCid` validation - ensure that the provided descriptorCid matches the CID of the actual message
const { descriptorCid } = payloadJson;
const expectedDescriptorCid = await Cid.computeCid(messageDescriptor);
if (descriptorCid !== expectedDescriptorCid) {
throw new DwnError(
DwnErrorCode.AuthenticateDescriptorCidMismatch,
`provided descriptorCid ${descriptorCid} does not match expected CID ${expectedDescriptorCid}`
);
}
return payloadJson;
}
}
@@ -0,0 +1,903 @@
import type { Filter } from '../types/query-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { RecordsDelete } from '../interfaces/records-delete.js';
import type { RecordsQuery } from '../interfaces/records-query.js';
import type { RecordsRead } from '../interfaces/records-read.js';
import type { RecordsSubscribe } from '../interfaces/records-subscribe.js';
import type { RecordsWriteMessage } from '../types/records-types.js';
import type { ProtocolActionRule, ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolType, ProtocolTypes } from '../types/protocols-types.js';
import Ajv from 'ajv/dist/2020.js';
import { FilterUtility } from '../utils/filter.js';
import { PermissionsProtocol } from '../protocols/permissions.js';
import { Records } from '../utils/records.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { ProtocolAction, ProtocolActor } from '../types/protocols-types.js';
export class ProtocolAuthorization {
/**
* Performs validation on the structure of RecordsWrite messages that use a protocol.
* @throws {Error} if validation fails.
*/
public static async validateReferentialIntegrity(
tenant: string,
incomingMessage: RecordsWrite,
messageStore: MessageStore,
): Promise<void> {
// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
incomingMessage.message.descriptor.protocol!,
messageStore,
);
// verify declared protocol type exists in protocol and that it conforms to type specification
ProtocolAuthorization.verifyType(
incomingMessage.message,
protocolDefinition.types
);
// validate `protocolPath`
await ProtocolAuthorization.verifyProtocolPathAndContextId(
tenant,
incomingMessage,
messageStore,
);
// get the rule set for the inbound message
const ruleSet = ProtocolAuthorization.getRuleSet(
incomingMessage.message.descriptor.protocolPath!,
protocolDefinition,
);
// Validate as a role record if the incoming message is writing a role record
await ProtocolAuthorization.verifyAsRoleRecordIfNeeded(
tenant,
incomingMessage,
ruleSet,
messageStore,
);
// Verify size limit
ProtocolAuthorization.verifySizeLimit(incomingMessage, ruleSet);
// Verify protocol tags
ProtocolAuthorization.verifyTagsIfNeeded(incomingMessage, ruleSet);
}
/**
* Performs protocol-based authorization against the incoming RecordsWrite message.
* @throws {Error} if authorization fails.
*/
public static async authorizeWrite(
tenant: string,
incomingMessage: RecordsWrite,
messageStore: MessageStore,
): Promise<void> {
const existingInitialWrite = await ProtocolAuthorization.fetchInitialWrite(tenant, incomingMessage.message.recordId, messageStore);
let recordChain;
if (existingInitialWrite === undefined) {
// NOTE: we can assume this message is an initial write because an existing initial write does not exist.
// Additionally, we check further down in the `RecordsWriteHandler` if the incoming message is an initialWrite,
// so we don't check explicitly here to avoid an unnecessary duplicate check.
recordChain = await ProtocolAuthorization.constructRecordChain(tenant, incomingMessage.message.descriptor.parentId, messageStore);
} else {
recordChain = await ProtocolAuthorization.constructRecordChain(tenant, incomingMessage.message.recordId, messageStore);
}
// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
incomingMessage.message.descriptor.protocol!,
messageStore,
);
// get the rule set for the inbound message
const ruleSet = ProtocolAuthorization.getRuleSet(
incomingMessage.message.descriptor.protocolPath!,
protocolDefinition,
);
// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
incomingMessage.message.descriptor.protocol!,
incomingMessage.message.contextId!,
protocolDefinition,
messageStore,
);
// verify method invoked against the allowed actions in the rule set
await ProtocolAuthorization.authorizeAgainstAllowedActions(
tenant,
incomingMessage,
ruleSet,
recordChain,
messageStore,
);
}
/**
* Performs protocol-based authorization against the incoming `RecordsRead` message.
* @param newestRecordsWrite The latest RecordsWrite associated with the recordId being read.
* @throws {Error} if authorization fails.
*/
public static async authorizeRead(
tenant: string,
incomingMessage: RecordsRead,
newestRecordsWrite: RecordsWrite,
messageStore: MessageStore,
): Promise<void> {
// fetch record chain
const recordChain: RecordsWriteMessage[] =
await ProtocolAuthorization.constructRecordChain(tenant, newestRecordsWrite.message.recordId, messageStore);
// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
newestRecordsWrite.message.descriptor.protocol!,
messageStore,
);
// get the rule set for the inbound message
const ruleSet = ProtocolAuthorization.getRuleSet(
newestRecordsWrite.message.descriptor.protocolPath!,
protocolDefinition,
);
// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
newestRecordsWrite.message.descriptor.protocol!,
newestRecordsWrite.message.contextId!,
protocolDefinition,
messageStore,
);
// verify method invoked against the allowed actions in the rule set
await ProtocolAuthorization.authorizeAgainstAllowedActions(
tenant,
incomingMessage,
ruleSet,
recordChain,
messageStore,
);
}
public static async authorizeQueryOrSubscribe(
tenant: string,
incomingMessage: RecordsQuery | RecordsSubscribe,
messageStore: MessageStore,
): Promise<void> {
const { protocol, protocolPath, contextId } = incomingMessage.message.descriptor.filter;
// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
protocol!, // `authorizeQueryOrSubscribe` is only called if `protocol` is present
messageStore,
);
// get the rule set for the inbound message
const ruleSet = ProtocolAuthorization.getRuleSet(
protocolPath!, // presence of `protocolPath` is verified in `parse()`
protocolDefinition,
);
// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
protocol!,
contextId,
protocolDefinition,
messageStore,
);
// verify method invoked against the allowed actions in the rule set
await ProtocolAuthorization.authorizeAgainstAllowedActions(
tenant,
incomingMessage,
ruleSet,
[], // record chain is not relevant to queries or subscriptions
messageStore,
);
}
/**
* Performs protocol-based authorization against the incoming `RecordsDelete` message.
* @param newestRecordsWrite The latest `RecordsWrite` associated with the recordId being deleted.
*/
public static async authorizeDelete(
tenant: string,
incomingMessage: RecordsDelete,
newestRecordsWrite: RecordsWrite,
messageStore: MessageStore,
): Promise<void> {
// fetch record chain
const recordChain: RecordsWriteMessage[] =
await ProtocolAuthorization.constructRecordChain(tenant, incomingMessage.message.descriptor.recordId, messageStore);
// fetch the protocol definition
const protocolDefinition = await ProtocolAuthorization.fetchProtocolDefinition(
tenant,
newestRecordsWrite.message.descriptor.protocol!,
messageStore,
);
// get the rule set for the inbound message
const ruleSet = ProtocolAuthorization.getRuleSet(
newestRecordsWrite.message.descriptor.protocolPath!,
protocolDefinition,
);
// If the incoming message has `protocolRole` in the descriptor, validate the invoked role
await ProtocolAuthorization.verifyInvokedRole(
tenant,
incomingMessage,
newestRecordsWrite.message.descriptor.protocol!,
newestRecordsWrite.message.contextId!,
protocolDefinition,
messageStore,
);
// verify method invoked against the allowed actions in the rule set
await ProtocolAuthorization.authorizeAgainstAllowedActions(
tenant,
incomingMessage,
ruleSet,
recordChain,
messageStore,
);
}
/**
* Fetches the protocol definition based on the protocol specified in the given message.
*/
private static async fetchProtocolDefinition(
tenant: string,
protocolUri: string,
messageStore: MessageStore
): Promise<ProtocolDefinition> {
// if first-class protocol, return the definition from const object directly without going to data store
if (protocolUri === PermissionsProtocol.uri) {
return PermissionsProtocol.definition;
}
// fetch the corresponding protocol definition
const query: Filter = {
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Configure,
protocol : protocolUri
};
const { messages: protocols } = await messageStore.query(tenant, [query]);
if (protocols.length === 0) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationProtocolNotFound, `unable to find protocol definition for ${protocolUri}`);
}
const protocolMessage = protocols[0] as ProtocolsConfigureMessage;
return protocolMessage.descriptor.definition;
}
/**
* Constructs the chain of EXISTING records in the datastore where the first record is the root initial `RecordsWrite` of the record chain
* and last record is the initial `RecordsWrite` of the descendant record specified.
* @param descendantRecordId The ID of the descendent record to start constructing the record chain from by repeatedly looking up the parent.
* @returns the record chain where each record is represented by its initial `RecordsWrite`;
* returns empty array if `descendantRecordId` is `undefined`.
* @throws {DwnError} if `descendantRecordId` is defined but any initial `RecordsWrite` is not found in the chain of records.
*/
private static async constructRecordChain(
tenant: string,
descendantRecordId: string | undefined,
messageStore: MessageStore
) : Promise<RecordsWriteMessage[]> {
if (descendantRecordId === undefined) {
return [];
}
const recordChain: RecordsWriteMessage[] = [];
// keep walking up the chain from the inbound message's parent, until there is no more parent
let currentRecordId: string | undefined = descendantRecordId;
while (currentRecordId !== undefined) {
const initialWrite = await ProtocolAuthorization.fetchInitialWrite(tenant, currentRecordId, messageStore);
// RecordsWrite needed should be available since we perform necessary checks at the time of writes,
// eg. check the immediate parent in `verifyProtocolPathAndContextId` at the time of writing,
// so if this condition is triggered, it means there is an unexpected bug that caused an incomplete chain.
// We add additional defensive check here because returning an unexpected/incorrect record chain could lead to security vulnerabilities.
if (initialWrite === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationParentNotFoundConstructingRecordChain,
`Unexpected error that should never trigger: no parent found with ID ${currentRecordId} when constructing record chain.`
);
}
recordChain.push(initialWrite);
currentRecordId = initialWrite.descriptor.parentId;
}
return recordChain.reverse(); // root record first
}
/**
* Fetches the initial RecordsWrite message associated with the given (tenant + recordId).
*/
private static async fetchInitialWrite(
tenant: string,
recordId: string,
messageStore: MessageStore
): Promise<RecordsWriteMessage | undefined> {
const query: Filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
recordId : recordId
};
const { messages } = await messageStore.query(tenant, [query]);
if (messages.length === 0) {
return undefined;
}
const initialWrite = await RecordsWrite.getInitialWrite(messages);
return initialWrite;
}
/**
* Gets the rule set corresponding to the given protocolPath.
*/
private static getRuleSet(
protocolPath: string,
protocolDefinition: ProtocolDefinition,
): ProtocolRuleSet {
const ruleSet = ProtocolAuthorization.getRuleSetAtProtocolPath(protocolPath, protocolDefinition);
if (ruleSet === undefined) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationMissingRuleSet,
`No rule set defined for protocolPath ${protocolPath}`);
}
return ruleSet;
}
/**
* Verifies the `protocolPath` declared in the given message (if it is a RecordsWrite) matches the path of actual record chain.
* @throws {DwnError} if fails verification.
*/
private static async verifyProtocolPathAndContextId(
tenant: string,
inboundMessage: RecordsWrite,
messageStore: MessageStore
): Promise<void> {
const declaredProtocolPath = inboundMessage.message.descriptor.protocolPath!;
const declaredTypeName = ProtocolAuthorization.getTypeName(declaredProtocolPath);
const parentId = inboundMessage.message.descriptor.parentId;
if (parentId === undefined) {
if (declaredProtocolPath !== declaredTypeName) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationParentlessIncorrectProtocolPath,
`Declared protocol path '${declaredProtocolPath}' is not valid for records with no parent'.`
);
}
return;
}
// Else `parentId` is defined, so we need to verify both protocolPath and contextId
// fetch the parent message
const protocol = inboundMessage.message.descriptor.protocol!;
const query: Filter = {
isLatestBaseState : true, // NOTE: this filter is critical, to ensure are are not returning a deleted parent
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol,
recordId : parentId
};
const { messages: parentMessages } = await messageStore.query(tenant, [query]);
const parentMessage = (parentMessages as RecordsWriteMessage[])[0];
// verifying protocolPath of incoming message is a child of the parent message's protocolPath
const parentProtocolPath = parentMessage?.descriptor?.protocolPath;
const expectedProtocolPath = `${parentProtocolPath}/${declaredTypeName}`;
if (expectedProtocolPath !== declaredProtocolPath) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationIncorrectProtocolPath,
`Could not find matching parent record to verify declared protocol path '${declaredProtocolPath}'.`
);
}
// verifying contextId of incoming message is a child of the parent message's contextId
const expectedContextId = `${parentMessage.contextId}/${inboundMessage.message.recordId}`;
const actualContextId = inboundMessage.message.contextId;
if (actualContextId !== expectedContextId) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationIncorrectContextId,
`Declared contextId '${actualContextId}' is not the same as expected: '${expectedContextId}'.`
);
}
}
/**
* Verifies the `dataFormat` and `schema` declared in the given message (if it is a RecordsWrite) matches dataFormat
* and schema of the type in the given protocol.
* @throws {DwnError} if fails verification.
*/
private static verifyType(
inboundMessage: RecordsWriteMessage,
protocolTypes: ProtocolTypes,
): void {
const typeNames = Object.keys(protocolTypes);
const declaredProtocolPath = inboundMessage.descriptor.protocolPath!;
const declaredTypeName = ProtocolAuthorization.getTypeName(declaredProtocolPath);
if (!typeNames.includes(declaredTypeName)) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationInvalidType,
`record with type ${declaredTypeName} not allowed in protocol`);
}
const protocolPath = inboundMessage.descriptor.protocolPath!;
// existence of `protocolType` has already been verified
const typeName = ProtocolAuthorization.getTypeName(protocolPath);
const protocolType: ProtocolType = protocolTypes[typeName];
// no `schema` specified in protocol definition means that any schema is allowed
const { schema } = inboundMessage.descriptor;
if (protocolType.schema !== undefined && protocolType.schema !== schema) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationInvalidSchema,
`type '${typeName}' must have schema '${protocolType.schema}', \
instead has '${schema}'`
);
}
// no `dataFormats` specified in protocol definition means that all dataFormats are allowed
const { dataFormat } = inboundMessage.descriptor;
if (protocolType.dataFormats !== undefined && !protocolType.dataFormats.includes(dataFormat)) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationIncorrectDataFormat,
`type '${typeName}' must have data format in (${protocolType.dataFormats}), \
instead has '${dataFormat}'`
);
}
}
/**
* Check if the incoming message is invoking a role. If so, validate the invoked role.
*/
private static async verifyInvokedRole(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
protocolUri: string,
contextId: string | undefined,
protocolDefinition: ProtocolDefinition,
messageStore: MessageStore,
): Promise<void> {
const protocolRole = incomingMessage.signaturePayload?.protocolRole;
// Only verify role if there is a role being invoked
if (protocolRole === undefined) {
return;
}
const roleRuleSet = ProtocolAuthorization.getRuleSetAtProtocolPath(protocolRole, protocolDefinition);
if (roleRuleSet === undefined || !roleRuleSet.$role) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationNotARole,
`Protocol path ${protocolRole} does not match role record type.`
);
}
// Construct a filter to fetch the invoked role record
const roleRecordFilter: Filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
protocol : protocolUri,
protocolPath : protocolRole,
recipient : incomingMessage.author!,
isLatestBaseState : true,
};
const ancestorSegmentCountOfRolePath = protocolRole.split('/').length - 1;
if (contextId === undefined && ancestorSegmentCountOfRolePath > 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationMissingContextId,
'Could not verify role because contextId is missing.'
);
}
// Compute `contextId` prefix filter for fetching the invoked role record if the role path is not at the root level.
// e.g. if invoked role path is `Thread/Participant`, and the `contextId` of the message is `threadX/messageY/attachmentZ`,
// then we need to add a prefix filter as `threadX` for the `contextId`
// because the `contextId` of the Participant record would be in the form of be `threadX/participantA`
if (ancestorSegmentCountOfRolePath > 0) {
const contextIdSegments = contextId!.split('/'); // NOTE: currently contextId segment count is never shorter than the role path count.
const contextIdPrefix = contextIdSegments.slice(0, ancestorSegmentCountOfRolePath).join('/');
const contextIdPrefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(contextIdPrefix);
roleRecordFilter.contextId = contextIdPrefixFilter;
}
const { messages: matchingMessages } = await messageStore.query(tenant, [roleRecordFilter]);
if (matchingMessages.length === 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationMatchingRoleRecordNotFound,
`No matching role record found for protocol path ${protocolRole}`
);
}
}
/**
* Returns all the ProtocolActions that would authorized the incoming message
* (but we still need to later verify if there is a rule defined that matches one of the actions).
* NOTE: the reason why there could be multiple actions is because:
* - In case of an initial RecordsWrite, the RecordsWrite can be authorized by an allow `create` or `write` rule.
* - In case of a non-initial RecordsWrite by the original record author, the RecordsWrite can be authorized by a `write` or `co-update` rule.
*
* It is important to recognize that the `write` access that allowed the original record author to create the record maybe revoked
* (e.g. by role revocation) by the time a "non-initial" write by the same author is attempted.
*/
private static async getActionsSeekingARuleMatch(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
messageStore: MessageStore,
): Promise<ProtocolAction[]> {
switch (incomingMessage.message.descriptor.method) {
case DwnMethodName.Delete:
const recordsDelete = incomingMessage as RecordsDelete;
const recordId = recordsDelete.message.descriptor.recordId;
const initialWrite = await RecordsWrite.fetchInitialRecordsWrite(messageStore, tenant, recordId);
// if there is no initial write, then no action rule can authorize the incoming message, because we won't know who the original author is
// NOTE: purely defensive programming: currently not reachable
// because RecordsDelete handler already have an existence check prior to this method being called.
if (initialWrite === undefined) {
return [];
}
const actionsThatWouldAuthorizeDelete = [];
const prune = recordsDelete.message.descriptor.prune;
if (prune) {
actionsThatWouldAuthorizeDelete.push(ProtocolAction.CoPrune);
// A prune by the original record author can also be authorized by a 'prune' rule.
if (incomingMessage.author === initialWrite.author) {
actionsThatWouldAuthorizeDelete.push(ProtocolAction.Prune);
}
} else {
actionsThatWouldAuthorizeDelete.push(ProtocolAction.CoDelete);
// A delete by the original record author can also be authorized by a 'delete' rule.
if (incomingMessage.author === initialWrite.author) {
actionsThatWouldAuthorizeDelete.push(ProtocolAction.Delete);
}
}
return actionsThatWouldAuthorizeDelete;
case DwnMethodName.Query:
return [ProtocolAction.Query];
case DwnMethodName.Read:
return [ProtocolAction.Read];
case DwnMethodName.Subscribe:
return [ProtocolAction.Subscribe];
case DwnMethodName.Write:
const incomingRecordsWrite = incomingMessage as RecordsWrite;
if (await incomingRecordsWrite.isInitialWrite()) {
return [ProtocolAction.Create];
} else {
// else incoming RecordsWrite not an initial write
const recordId = (incomingMessage as RecordsWrite).message.recordId;
const initialWrite = await RecordsWrite.fetchInitialRecordsWrite(messageStore, tenant, recordId);
// if there is no initial write to update from, then no action rule can authorize the incoming message
if (initialWrite === undefined) {
return [];
}
if (incomingMessage.author === initialWrite.author) {
// 'update' or 'co-update' action authorizes the incoming message
return [ProtocolAction.CoUpdate, ProtocolAction.Update];
} else {
// An update by someone who is not the record author can only be authorized by a 'co-update' rule.
return [ProtocolAction.CoUpdate];
}
}
}
// purely defensive programming: should not be reachable
// setting to empty array will prevent any message from being authorized
return [];
}
/**
* Verifies the given message is authorized by one of the action rules in the given protocol rule set.
* @throws {Error} if action not allowed.
*/
private static async authorizeAgainstAllowedActions(
tenant: string,
incomingMessage: RecordsDelete | RecordsQuery | RecordsRead | RecordsSubscribe | RecordsWrite,
ruleSet: ProtocolRuleSet,
recordChain: RecordsWriteMessage[],
messageStore: MessageStore,
): Promise<void> {
const incomingMessageMethod = incomingMessage.message.descriptor.method;
const actionsSeekingARuleMatch = await ProtocolAuthorization.getActionsSeekingARuleMatch(tenant, incomingMessage, messageStore);
const author = incomingMessage.author;
const actionRules = ruleSet.$actions;
// NOTE: We have already checked that the message is not from tenant, owner, or permission grant authorized prior to this method being called.
if (actionRules === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationActionRulesNotFound,
`no action rule defined for Records${incomingMessageMethod}, ${author} is unauthorized`
);
}
const invokedRole = incomingMessage.signaturePayload?.protocolRole;
// Iterate through the action rules to find a rule that authorizes the incoming message.
for (const actionRule of actionRules) {
// If the action rule does not have an allowed action that matches an action that can authorize the message, skip to evaluate next action rule.
const ruleHasAMatchingAllowedAction = actionRule.can.some(allowedAction => actionsSeekingARuleMatch.includes(allowedAction as ProtocolAction));
if (!ruleHasAMatchingAllowedAction) {
continue;
}
// Code reaches here means this action rule has an allowed action that matches the action of the message.
// The remaining code checks the actor/author of the incoming message.
// If the action rule allows `anyone`, then no further checks are needed.
if (actionRule.who === ProtocolActor.Anyone) {
return;
}
// Since not `anyone` is allowed in this action rule, we will need to check the author of the incoming message,
// if the author of incoming message is not defined, this action rule cannot authorize the incoming message.
if (author === undefined) {
continue;
}
// go through role validation path if a role is invoked by the incoming message
if (invokedRole !== undefined) {
// When a protocol role is being invoked, we require that there is a matching `role` rule.
if (actionRule.role === invokedRole) {
// role is successfully invoked
return;
} else {
continue;
}
}
// else we go through the actor (`who`) validation
// If `of` is not set, handle it as a special case
// NOTE: `of` is always set if `who` is set to `author` (we do this check in `validateRuleSetRecursively()`)
if (actionRule.who === ProtocolActor.Recipient && actionRule.of === undefined) {
// If the action rule specifies a recipient without `of` and the incoming message is authenticated:
// Author must be recipient of the record being accessed
let recordsWriteMessage: RecordsWriteMessage;
if (incomingMessage.message.descriptor.method === DwnMethodName.Write) {
recordsWriteMessage = incomingMessage.message as RecordsWriteMessage;
} else {
// else the incoming message must be a `RecordsDelete` because only `co-update`, `co-delete`, `co-prune` are allowed recipient actions,
// (we do this check in `validateRuleSetRecursively()`)
// and we have already checked that the incoming message is not a `RecordsWrite` above which covers `co-update` path.
recordsWriteMessage = recordChain[recordChain.length - 1];
}
if (recordsWriteMessage.descriptor.recipient === author) {
return;
} else {
continue;
}
}
// validate the actor is allowed by the current action rule
const ancestorRuleSuccess: boolean = await ProtocolAuthorization.checkActor(author, actionRule, recordChain);
if (ancestorRuleSuccess) {
return;
}
}
// No action rules were satisfied, message is not authorized
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationActionNotAllowed,
`Inbound message action Records${incomingMessageMethod} by author ${incomingMessage.author} not allowed.`
);
}
/**
* Verifies that writes adhere to the $size constraints if provided
* @throws {Error} if size is exceeded.
*/
private static verifySizeLimit(
incomingMessage: RecordsWrite,
ruleSet: ProtocolRuleSet
): void {
const { min = 0, max } = ruleSet.$size || {};
const dataSize = incomingMessage.message.descriptor.dataSize;
if (dataSize < min) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationMinSizeInvalid, `data size ${dataSize} is less than allowed ${min}`);
}
if (max === undefined) {
return;
}
if (dataSize > max) {
throw new DwnError(DwnErrorCode.ProtocolAuthorizationMaxSizeInvalid, `data size ${dataSize} is more than allowed ${max}`);
}
}
private static verifyTagsIfNeeded(
incomingMessage: RecordsWrite,
ruleSet: ProtocolRuleSet
): void {
if (ruleSet.$tags !== undefined) {
const { tags = {}, protocol, protocolPath } = incomingMessage.message.descriptor;
const { $allowUndefinedTags, $requiredTags, ...properties } = ruleSet.$tags;
// if $allowUndefinedTags is set to false and there are properties not defined in the schema, an error is thrown
const additionalProperties = $allowUndefinedTags || false;
// if $requiredTags is set, all required tags must be present
const required = $requiredTags || [];
const ajv = new Ajv.default();
const compiledTags = ajv.compile({
type: 'object',
properties,
required,
additionalProperties,
});
const validSchema = compiledTags(tags);
if (!validSchema) {
// the `dataVar` is used to add a qualifier to the error message.
// For example. If the error is related to a tag `status` in a protocol `https://example.protocol` with the protocolPath `example/path`
// the error would be described as `https://example.protocol/example/path/$tags/status'
// without this decorator it would show up as `data/status` which may be confusing.
const schemaError = ajv.errorsText(compiledTags.errors, { dataVar: `${protocol}/${protocolPath}/$tags` });
throw new DwnError(DwnErrorCode.ProtocolAuthorizationTagsInvalidSchema, `tags schema validation error: ${schemaError}`);
}
}
}
/**
* If the given RecordsWrite is not a role record, this method does nothing and succeeds immediately.
*
* Else it verifies the validity of the given `RecordsWrite` as a role record, including:
* 1. The same role has not been assigned to the same entity/recipient.
*/
private static async verifyAsRoleRecordIfNeeded(
tenant: string,
incomingMessage: RecordsWrite,
ruleSet: ProtocolRuleSet,
messageStore: MessageStore,
): Promise<void> {
if (!ruleSet.$role) {
return;
}
// else this is a role record
const incomingRecordsWrite = incomingMessage;
const recipient = incomingRecordsWrite.message.descriptor.recipient;
if (recipient === undefined) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationRoleMissingRecipient,
'Role records must have a recipient'
);
}
const protocolPath = incomingRecordsWrite.message.descriptor.protocolPath!;
const filter: Filter = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true,
protocol : incomingRecordsWrite.message.descriptor.protocol!,
protocolPath,
recipient,
};
const parentContextId = Records.getParentContextFromOfContextId(incomingRecordsWrite.message.contextId)!;
// if this is not the root record, add a prefix filter to the query
if (parentContextId !== '') {
const prefixFilter = FilterUtility.constructPrefixFilterAsRangeFilter(parentContextId);
filter.contextId = prefixFilter;
}
const { messages: matchingMessages } = await messageStore.query(tenant, [filter]);
const matchingRecords = matchingMessages as RecordsWriteMessage[];
const matchingRecordsExceptIncomingRecordId = matchingRecords.filter((recordsWriteMessage) =>
recordsWriteMessage.recordId !== incomingRecordsWrite.message.recordId
);
if (matchingRecordsExceptIncomingRecordId.length > 0) {
throw new DwnError(
DwnErrorCode.ProtocolAuthorizationDuplicateRoleRecipient,
`DID '${recipient}' is already recipient of a role record at protocol path '${protocolPath} under the parent context ${parentContextId}.`
);
}
}
private static getRuleSetAtProtocolPath(protocolPath: string, protocolDefinition: ProtocolDefinition): ProtocolRuleSet | undefined {
const protocolPathArray = protocolPath.split('/');
let currentRuleSet: ProtocolRuleSet = protocolDefinition.structure;
let i = 0;
while (i < protocolPathArray.length) {
const currentTypeName = protocolPathArray[i];
const nextRuleSet: ProtocolRuleSet | undefined = currentRuleSet[currentTypeName];
if (nextRuleSet === undefined) {
return undefined;
}
currentRuleSet = nextRuleSet;
i++;
}
return currentRuleSet;
}
/**
* Checks if the `who: 'author' | 'recipient'` action rule has a matching record in the record chain.
* @returns `true` if the action rule is satisfied; `false` otherwise.
*/
private static async checkActor(
author: string,
actionRule: ProtocolActionRule,
recordChain: RecordsWriteMessage[],
): Promise<boolean> {
// find a message with matching protocolPath
const ancestorRecordsWrite = recordChain.find((recordsWriteMessage) =>
recordsWriteMessage.descriptor.protocolPath === actionRule.of!
);
if (ancestorRecordsWrite === undefined) {
// If this is reached, there is likely an issue with the protocol definition.
// The protocolPath to the actionRule should start with actionRule.of
// consider moving this check to ProtocolsConfigure message ingestion
return false;
}
if (actionRule.who === ProtocolActor.Recipient) {
// author of the incoming message must be the recipient of the ancestor message
return author === ancestorRecordsWrite.descriptor.recipient;
} else { // actionRule.who === ProtocolActor.Author
// author of the incoming message must be the author of the ancestor message
const ancestorAuthor = (await RecordsWrite.parse(ancestorRecordsWrite)).author;
return author === ancestorAuthor;
}
}
private static getTypeName(protocolPath: string): string {
return protocolPath.split('/').slice(-1)[0];
}
}
@@ -0,0 +1,248 @@
import type { MessageStore } from '../types/message-store.js';
import type { PermissionGrant } from '../protocols/permission-grant.js';
import type { PermissionConditions, RecordsPermissionScope } from '../types/permission-types.js';
import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteMessage } from '../types/records-types.js';
import { GrantAuthorization } from './grant-authorization.js';
import { PermissionConditionPublication } from '../types/permission-types.js';
import { DwnError, DwnErrorCode } from './dwn-error.js';
export class RecordsGrantAuthorization {
/**
* Authorizes the given RecordsWrite in the scope of the DID given.
*/
public static async authorizeWrite(input: {
recordsWriteMessage: RecordsWriteMessage,
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
}): Promise<void> {
const {
recordsWriteMessage, expectedGrantor, expectedGrantee, permissionGrant, messageStore
} = input;
await GrantAuthorization.performBaseValidation({
incomingMessage: recordsWriteMessage,
expectedGrantor,
expectedGrantee,
permissionGrant,
messageStore
});
// NOTE: validated the invoked permission is for Records in GrantAuthorization.performBaseValidation()
RecordsGrantAuthorization.verifyScope(recordsWriteMessage, permissionGrant.scope as RecordsPermissionScope);
RecordsGrantAuthorization.verifyConditions(recordsWriteMessage, permissionGrant.conditions);
}
/**
* Authorizes a RecordsReadMessage using the given permission grant.
* @param messageStore Used to check if the given grant has been revoked.
*/
public static async authorizeRead(input: {
recordsReadMessage: RecordsReadMessage,
recordsWriteMessageToBeRead: RecordsWriteMessage,
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
}): Promise<void> {
const {
recordsReadMessage, recordsWriteMessageToBeRead, expectedGrantor, expectedGrantee, permissionGrant, messageStore
} = input;
await GrantAuthorization.performBaseValidation({
incomingMessage: recordsReadMessage,
expectedGrantor,
expectedGrantee,
permissionGrant,
messageStore
});
// NOTE: validated the invoked permission is for Records in GrantAuthorization.performBaseValidation()
RecordsGrantAuthorization.verifyScope(recordsWriteMessageToBeRead, permissionGrant.scope as RecordsPermissionScope);
}
/**
* Authorizes the scope of a permission grant for RecordsQuery or RecordsSubscribe.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeQueryOrSubscribe(input: {
incomingMessage: RecordsQueryMessage | RecordsSubscribeMessage,
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
}): Promise<void> {
const {
incomingMessage, expectedGrantor, expectedGrantee, permissionGrant, messageStore
} = input;
await GrantAuthorization.performBaseValidation({
incomingMessage,
expectedGrantor,
expectedGrantee,
permissionGrant,
messageStore
});
// If the grant specifies a protocol, the subscribe or query must specify the same protocol.
// NOTE: validated the invoked permission is for Records in GrantAuthorization.performBaseValidation()
const permissionScope = permissionGrant.scope as RecordsPermissionScope;
const protocolInGrant = permissionScope.protocol;
const protocolInMessage = incomingMessage.descriptor.filter.protocol;
if (protocolInGrant !== undefined && protocolInMessage !== protocolInGrant) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationQueryOrSubscribeProtocolScopeMismatch,
`Grant protocol scope ${protocolInGrant} does not match protocol in message ${protocolInMessage}`
);
}
}
/**
* Authorizes the scope of a permission grant for RecordsDelete.
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeDelete(input: {
recordsDeleteMessage: RecordsDeleteMessage,
recordsWriteToDelete: RecordsWriteMessage,
expectedGrantor: string,
expectedGrantee: string,
permissionGrant: PermissionGrant,
messageStore: MessageStore,
}): Promise<void> {
const {
recordsDeleteMessage, recordsWriteToDelete, expectedGrantor, expectedGrantee, permissionGrant, messageStore
} = input;
await GrantAuthorization.performBaseValidation({
incomingMessage: recordsDeleteMessage,
expectedGrantor,
expectedGrantee,
permissionGrant,
messageStore
});
// If the grant specifies a protocol, the delete must be deleting a record with the same protocol.
// NOTE: validated the invoked permission is for Records in GrantAuthorization.performBaseValidation()
const permissionScope = permissionGrant.scope as RecordsPermissionScope;
const protocolInGrant = permissionScope.protocol;
const protocolOfRecordToDelete = recordsWriteToDelete.descriptor.protocol;
if (protocolInGrant !== undefined && protocolOfRecordToDelete !== protocolInGrant) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationDeleteProtocolScopeMismatch,
`Grant protocol scope ${protocolInGrant} does not match protocol in record to delete ${protocolOfRecordToDelete}`
);
}
}
/**
* Verifies the given record against the scope of the given grant.
*/
private static verifyScope(
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope,
): void {
if (RecordsGrantAuthorization.isUnrestrictedScope(grantScope)) {
// scope has no restrictions beyond interface and method. Message is authorized to access any record.
return;
} else if (recordsWriteMessage.descriptor.protocol !== undefined) {
// authorization of protocol records must have grants that explicitly include the protocol
RecordsGrantAuthorization.verifyProtocolRecordScope(recordsWriteMessage, grantScope);
} else {
RecordsGrantAuthorization.verifyFlatRecordScope(recordsWriteMessage, grantScope);
}
}
/**
* Verifies a protocol record against the scope of the given grant.
*/
private static verifyProtocolRecordScope(
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
// Protocol records must have grants specifying the protocol
if (grantScope.protocol === undefined) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeMissingProtocol,
'Grant for protocol record must specify protocol in its scope'
);
}
// The record's protocol must match the protocol specified in the record
if (grantScope.protocol !== recordsWriteMessage.descriptor.protocol) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolMismatch,
`Grant scope specifies different protocol than what appears in the record`
);
}
// If grant specifies a contextId, check that record falls under that contextId
if (grantScope.contextId !== undefined) {
if (recordsWriteMessage.contextId === undefined || !recordsWriteMessage.contextId.startsWith(grantScope.contextId)) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeContextIdMismatch,
`Grant scope specifies different contextId than what appears in the record`
);
}
}
// If grant specifies protocolPath, check that record is at that protocolPath
if (grantScope.protocolPath !== undefined && grantScope.protocolPath !== recordsWriteMessage.descriptor.protocolPath) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeProtocolPathMismatch,
`Grant scope specifies different protocolPath than what appears in the record`
);
}
}
/**
* Verifies a non-protocol record against the scope of the given grant.
*/
private static verifyFlatRecordScope(
recordsWriteMessage: RecordsWriteMessage,
grantScope: RecordsPermissionScope
): void {
if (grantScope.schema !== undefined) {
if (grantScope.schema !== recordsWriteMessage.descriptor.schema) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationScopeSchema,
`Record does not have schema in permission grant scope with schema '${grantScope.schema}'`
);
}
}
}
/**
* Verifies grant `conditions`.
* Currently the only condition is `published` which only applies to RecordsWrites
*/
private static verifyConditions(recordsWriteMessage: RecordsWriteMessage, conditions: PermissionConditions | undefined): void {
// If conditions require publication, RecordsWrite must have `published` === true
if (conditions?.publication === PermissionConditionPublication.Required && !recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationRequired,
'Permission grant requires message to be published'
);
}
// if conditions prohibit publication, RecordsWrite must have published === false or undefined
if (conditions?.publication === PermissionConditionPublication.Prohibited && recordsWriteMessage.descriptor.published) {
throw new DwnError(
DwnErrorCode.RecordsGrantAuthorizationConditionPublicationProhibited,
'Permission grant prohibits message from being published'
);
}
}
/**
* Checks if scope has no restrictions beyond interface and method.
* Grant-holder is authorized to access any record.
*/
private static isUnrestrictedScope(grantScope: RecordsPermissionScope): boolean {
return grantScope.protocol === undefined &&
grantScope.schema === undefined;
}
}
@@ -0,0 +1,33 @@
/**
* The result of the isActiveTenant() call.
*/
export type ActiveTenantCheckResult = {
/**
* `true` if the given DID is an active tenant of the DWN; `false` otherwise.
*/
isActiveTenant: boolean;
/**
* An optional detail message if the given DID is not an active tenant of the DWN.
*/
detail?: string;
};
/**
* An interface that gates tenant access to the DWN.
*/
export interface TenantGate {
/**
* @returns `true` if the given DID is an active tenant of the DWN; `false` otherwise
*/
isActiveTenant(did: string): Promise<ActiveTenantCheckResult>;
}
/**
* A tenant gate that treats every DID as an active tenant.
*/
export class AllowAllTenantGate implements TenantGate {
public async isActiveTenant(_did: string): Promise<ActiveTenantCheckResult> {
return { isActiveTenant: true };
}
}
+247
View File
@@ -0,0 +1,247 @@
import type { DataStore } from './types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { EventLog } from './types/event-log.js';
import type { EventStream } from './types/subscriptions.js';
import type { MessageStore } from './types/message-store.js';
import type { MethodHandler } from './types/method-handler.js';
import type { Readable } from 'readable-stream';
import type { TenantGate } from './core/tenant-gate.js';
import type { UnionMessageReply } from './core/message-reply.js';
import type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeMessage, EventsSubscribeMessageOptions, EventsSubscribeReply, MessageSubscriptionHandler } from './types/events-types.js';
import type { GenericMessage, GenericMessageReply } from './types/message-types.js';
import type { MessagesGetMessage, MessagesGetReply } from './types/messages-types.js';
import type { ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js';
import type { RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsReadMessage, RecordsReadReply, RecordsSubscribeMessage, RecordsSubscribeMessageOptions, RecordsSubscribeReply, RecordSubscriptionHandler, RecordsWriteMessage, RecordsWriteMessageOptions } from './types/records-types.js';
import { AllowAllTenantGate } from './core/tenant-gate.js';
import { EventsGetHandler } from './handlers/events-get.js';
import { EventsQueryHandler } from './handlers/events-query.js';
import { EventsSubscribeHandler } from './handlers/events-subscribe.js';
import { Message } from './core/message.js';
import { messageReplyFromError } from './core/message-reply.js';
import { MessagesGetHandler } from './handlers/messages-get.js';
import { ProtocolsConfigureHandler } from './handlers/protocols-configure.js';
import { ProtocolsQueryHandler } from './handlers/protocols-query.js';
import { RecordsDeleteHandler } from './handlers/records-delete.js';
import { RecordsQueryHandler } from './handlers/records-query.js';
import { RecordsReadHandler } from './handlers/records-read.js';
import { RecordsSubscribeHandler } from './handlers/records-subscribe.js';
import { RecordsWriteHandler } from './handlers/records-write.js';
import { DidDht, DidIon, DidKey, DidResolverCacheLevel, UniversalResolver } from '@web5/dids';
import { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js';
export class Dwn {
private methodHandlers: { [key:string]: MethodHandler };
private didResolver: DidResolver;
private messageStore: MessageStore;
private dataStore: DataStore;
private eventLog: EventLog;
private tenantGate: TenantGate;
private eventStream?: EventStream;
private constructor(config: DwnConfig) {
this.didResolver = config.didResolver!;
this.tenantGate = config.tenantGate!;
this.eventStream = config.eventStream!;
this.messageStore = config.messageStore;
this.dataStore = config.dataStore;
this.eventLog = config.eventLog;
this.eventStream = config.eventStream;
this.methodHandlers = {
[DwnInterfaceName.Events + DwnMethodName.Get]: new EventsGetHandler(
this.didResolver,
this.eventLog,
),
[DwnInterfaceName.Events + DwnMethodName.Query]: new EventsQueryHandler(
this.didResolver,
this.eventLog,
),
[DwnInterfaceName.Events+ DwnMethodName.Subscribe]: new EventsSubscribeHandler(
this.didResolver,
this.eventStream,
),
[DwnInterfaceName.Messages + DwnMethodName.Get]: new MessagesGetHandler(
this.didResolver,
this.messageStore,
this.dataStore,
),
[DwnInterfaceName.Protocols + DwnMethodName.Configure]: new ProtocolsConfigureHandler(
this.didResolver,
this.messageStore,
this.eventLog,
this.eventStream
),
[DwnInterfaceName.Protocols + DwnMethodName.Query]: new ProtocolsQueryHandler(
this.didResolver,
this.messageStore,
this.dataStore
),
[DwnInterfaceName.Records + DwnMethodName.Delete]: new RecordsDeleteHandler(
this.didResolver,
this.messageStore,
this.dataStore,
this.eventLog,
this.eventStream
),
[DwnInterfaceName.Records + DwnMethodName.Query]: new RecordsQueryHandler(
this.didResolver,
this.messageStore,
this.dataStore
),
[DwnInterfaceName.Records + DwnMethodName.Read]: new RecordsReadHandler(
this.didResolver,
this.messageStore,
this.dataStore
),
[DwnInterfaceName.Records + DwnMethodName.Subscribe]: new RecordsSubscribeHandler(
this.didResolver,
this.messageStore,
this.eventStream
),
[DwnInterfaceName.Records + DwnMethodName.Write]: new RecordsWriteHandler(
this.didResolver,
this.messageStore,
this.dataStore,
this.eventLog,
this.eventStream
)
};
}
/**
* Creates an instance of the DWN.
*/
public static async create(config: DwnConfig): Promise<Dwn> {
config.didResolver ??= new UniversalResolver({
didResolvers : [DidDht, DidIon, DidKey],
cache : new DidResolverCacheLevel({ location: 'RESOLVERCACHE' }),
});
config.tenantGate ??= new AllowAllTenantGate();
const dwn = new Dwn(config);
await dwn.open();
return dwn;
}
private async open(): Promise<void> {
await this.messageStore.open();
await this.dataStore.open();
await this.eventLog.open();
await this.eventStream?.open();
}
public async close(): Promise<void> {
await this.eventStream?.close();
await this.messageStore.close();
await this.dataStore.close();
await this.eventLog.close();
}
/**
* Processes the given DWN message and returns with a reply.
* @param tenant The tenant DID to route the given message to.
*/
public async processMessage(tenant: string, rawMessage: EventsGetMessage): Promise<EventsGetReply>;
public async processMessage(tenant: string, rawMessage: EventsQueryMessage): Promise<EventsQueryReply>;
public async processMessage(
tenant: string, rawMessage: EventsSubscribeMessage, options?: EventsSubscribeMessageOptions): Promise<EventsSubscribeReply>;
public async processMessage(tenant: string, rawMessage: MessagesGetMessage): Promise<MessagesGetReply>;
public async processMessage(tenant: string, rawMessage: ProtocolsConfigureMessage): Promise<GenericMessageReply>;
public async processMessage(tenant: string, rawMessage: ProtocolsQueryMessage): Promise<ProtocolsQueryReply>;
public async processMessage(tenant: string, rawMessage: RecordsDeleteMessage): Promise<GenericMessageReply>;
public async processMessage(tenant: string, rawMessage: RecordsQueryMessage): Promise<RecordsQueryReply>;
public async processMessage(
tenant: string, rawMessage: RecordsSubscribeMessage, options: RecordsSubscribeMessageOptions): Promise<RecordsSubscribeReply>;
public async processMessage(tenant: string, rawMessage: RecordsReadMessage): Promise<RecordsReadReply>;
public async processMessage(tenant: string, rawMessage: RecordsWriteMessage, options?: RecordsWriteMessageOptions): Promise<GenericMessageReply>;
public async processMessage(tenant: string, rawMessage: unknown, options?: MessageOptions): Promise<UnionMessageReply>;
public async processMessage(tenant: string, rawMessage: GenericMessage, options: MessageOptions = {}): Promise<UnionMessageReply> {
const errorMessageReply = await this.validateTenant(tenant) ?? await this.validateMessageIntegrity(rawMessage);
if (errorMessageReply !== undefined) {
return errorMessageReply;
}
const { dataStream, subscriptionHandler } = options;
const handlerKey = rawMessage.descriptor.interface + rawMessage.descriptor.method;
const methodHandlerReply = await this.methodHandlers[handlerKey].handle({
tenant,
message: rawMessage as GenericMessage,
dataStream,
subscriptionHandler
});
return methodHandlerReply;
}
/**
* Checks tenant gate to see if tenant is allowed.
* @param tenant The tenant DID to route the given message to.
* @returns GenericMessageReply if the message has an integrity error, otherwise undefined.
*/
public async validateTenant(tenant: string): Promise<GenericMessageReply | undefined> {
const result = await this.tenantGate.isActiveTenant(tenant);
if (!result.isActiveTenant) {
const detail = result.detail ?? `DID ${tenant} is not an active tenant.`;
return {
status: { code: 401, detail }
};
}
}
/**
* Validates structure of DWN message
* @param tenant The tenant DID to route the given message to.
* @param dwnMessageInterface The interface of DWN message.
* @param dwnMessageMethod The interface of DWN message.
* @returns GenericMessageReply if the message has an integrity error, otherwise undefined.
*/
public async validateMessageIntegrity(
rawMessage: any,
): Promise<GenericMessageReply | undefined> {
// Verify interface and method
const dwnInterface = rawMessage?.descriptor?.interface;
const dwnMethod = rawMessage?.descriptor?.method;
if (dwnInterface === undefined || dwnMethod === undefined) {
return {
status: { code: 400, detail: `Both interface and method must be present, interface: ${dwnInterface}, method: ${dwnMethod}` }
};
}
// validate message structure
try {
// consider to push this down to individual handlers
Message.validateJsonSchema(rawMessage);
} catch (error) {
return messageReplyFromError(error, 400);
}
}
};
/**
* MessageOptions that are used when processing a message.
*/
export interface MessageOptions {
dataStream?: Readable;
subscriptionHandler?: MessageSubscriptionHandler | RecordSubscriptionHandler;
};
/**
* DWN configuration.
*/
export type DwnConfig = {
didResolver?: DidResolver;
tenantGate?: TenantGate;
// event stream is optional if a DWN does not wish to provide subscription services.
eventStream?: EventStream;
messageStore: MessageStore;
dataStore: DataStore;
eventLog: EventLog;
};
@@ -0,0 +1,20 @@
export enum DwnInterfaceName {
Events = 'Events',
Messages = 'Messages',
Protocols = 'Protocols',
Records = 'Records'
}
export enum DwnMethodName {
Configure = 'Configure',
Create = 'Create',
Get = 'Get',
Grant = 'Grant',
Query = 'Query',
Read = 'Read',
Request = 'Request',
Revoke = 'Revoke',
Write = 'Write',
Delete = 'Delete',
Subscribe = 'Subscribe'
}
@@ -0,0 +1,69 @@
import type { KeyValues } from '../types/query-types.js';
import type { EventListener, EventStream, EventSubscription, MessageEvent } from '../types/subscriptions.js';
import { EventEmitter } from 'events';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
const EVENTS_LISTENER_CHANNEL = 'events';
export interface EventEmitterStreamConfig {
/**
* An optional error handler in order to be able to react to any errors or warnings triggers by `EventEmitter`.
* By default we log errors with `console.error`.
*/
errorHandler?: (error: any) => void;
};
export class EventEmitterStream implements EventStream {
private eventEmitter: EventEmitter;
private isOpen: boolean = false;
constructor(config: EventEmitterStreamConfig = {}) {
// we capture the rejections and currently just log the errors that are produced
this.eventEmitter = new EventEmitter({ captureRejections: true });
// number of listeners per particular eventName before a warning is emitted
// we set to 0 which represents infinity.
// https://nodejs.org/api/events.html#emittersetmaxlistenersn
this.eventEmitter.setMaxListeners(0);
if (config.errorHandler) {
this.errorHandler = config.errorHandler;
}
this.eventEmitter.on('error', this.errorHandler);
}
/**
* we subscribe to the `EventEmitter` error handler with a provided handler or set one which logs the errors.
*/
private errorHandler: (error:any) => void = (error) => { console.error('event emitter error', error); };
async subscribe(tenant: string, id: string, listener: EventListener): Promise<EventSubscription> {
this.eventEmitter.on(`${tenant}_${EVENTS_LISTENER_CHANNEL}`, listener);
return {
id,
close: async (): Promise<void> => { this.eventEmitter.off(`${tenant}_${EVENTS_LISTENER_CHANNEL}`, listener); }
};
}
async open(): Promise<void> {
this.isOpen = true;
}
async close(): Promise<void> {
this.isOpen = false;
this.eventEmitter.removeAllListeners();
}
emit(tenant: string, event: MessageEvent, indexes: KeyValues): void {
if (!this.isOpen) {
this.errorHandler(new DwnError(
DwnErrorCode.EventEmitterStreamNotOpenError,
'a message emitted when EventEmitterStream is closed'
));
return;
}
this.eventEmitter.emit(`${tenant}_${EVENTS_LISTENER_CHANNEL}`, tenant, event, indexes);
}
}
@@ -0,0 +1,72 @@
import type { EventLog } from '../types/event-log.js';
import type { EventStream } from '../types/subscriptions.js';
import type { ULIDFactory } from 'ulidx';
import type { Filter, KeyValues, PaginationCursor } from '../types/query-types.js';
import { createLevelDatabase } from '../store/level-wrapper.js';
import { IndexLevel } from '../store/index-level.js';
import { monotonicFactory } from 'ulidx';
type EventLogLevelConfig = {
/**
* must be a directory path (relative or absolute) where
* LevelDB will store its files, or in browsers, the name of the
* {@link https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase IDBDatabase} to be opened.
*/
location?: string,
createLevelDatabase?: typeof createLevelDatabase,
eventStream?: EventStream,
};
export class EventLogLevel implements EventLog {
ulidFactory: ULIDFactory;
index: IndexLevel;
constructor(config?: EventLogLevelConfig) {
this.index = new IndexLevel({
location: 'EVENTLOG',
createLevelDatabase,
...config,
});
this.ulidFactory = monotonicFactory();
}
async open(): Promise<void> {
return this.index.open();
}
async close(): Promise<void> {
return this.index.close();
}
async clear(): Promise<void> {
return this.index.clear();
}
async append(tenant: string, messageCid: string, indexes: KeyValues): Promise<void> {
const watermark = this.ulidFactory();
await this.index.put(tenant, messageCid, { ...indexes, watermark });
}
async queryEvents(tenant: string, filters: Filter[], cursor?: PaginationCursor): Promise<{ events: string[], cursor?: PaginationCursor }> {
const results = await this.index.query(tenant, filters, { sortProperty: 'watermark', cursor });
return {
events : results.map(({ messageCid }) => messageCid),
cursor : IndexLevel.createCursorFromLastArrayItem(results, 'watermark'),
};
}
async getEvents(tenant: string, cursor?: PaginationCursor): Promise<{ events: string[], cursor?: PaginationCursor }> {
return this.queryEvents(tenant, [], cursor);
}
async deleteEventsByCid(tenant: string, messageCids: Array<string>): Promise<void> {
const indexDeletePromises: Promise<void>[] = [];
for (const messageCid of messageCids) {
indexDeletePromises.push(this.index.delete(tenant, messageCid));
}
await Promise.all(indexDeletePromises);
}
}
@@ -0,0 +1,42 @@
import type { DidResolver } from '@web5/dids';
import type { EventLog } from '../types/event-log.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { EventsGetMessage, EventsGetReply } from '../types/events-types.js';
import { EventsGet } from '../interfaces/events-get.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { authenticate, authorizeOwner } from '../core/auth.js';
type HandleArgs = {tenant: string, message: EventsGetMessage};
export class EventsGetHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private eventLog: EventLog) {}
public async handle({ tenant, message }: HandleArgs): Promise<EventsGetReply> {
let eventsGet: EventsGet;
try {
eventsGet = await EventsGet.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
try {
await authenticate(message.authorization, this.didResolver);
await authorizeOwner(tenant, eventsGet);
} catch (e) {
return messageReplyFromError(e, 401);
}
// if a cursor was provided in message, get all events _after_ the cursor.
// Otherwise, get all events.
const { cursor: queryCursor } = message.descriptor;
const { events, cursor } = await this.eventLog.getEvents(tenant, queryCursor);
return {
status : { code: 200, detail: 'OK' },
entries : events,
cursor
};
}
}
@@ -0,0 +1,44 @@
import type { DidResolver } from '@web5/dids';
import type { EventLog } from '../types/event-log.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { EventsQueryMessage, EventsQueryReply } from '../types/events-types.js';
import { Events } from '../utils/events.js';
import { EventsQuery } from '../interfaces/events-query.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { authenticate, authorizeOwner } from '../core/auth.js';
export class EventsQueryHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private eventLog: EventLog) { }
public async handle({
tenant,
message
}: {tenant: string, message: EventsQueryMessage}): Promise<EventsQueryReply> {
let eventsQuery: EventsQuery;
try {
eventsQuery = await EventsQuery.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
try {
await authenticate(message.authorization, this.didResolver);
await authorizeOwner(tenant, eventsQuery);
} catch (e) {
return messageReplyFromError(e, 401);
}
const eventFilters = Events.convertFilters(message.descriptor.filters);
const { events, cursor } = await this.eventLog.queryEvents(tenant, eventFilters, message.descriptor.cursor);
return {
status : { code: 200, detail: 'OK' },
entries : events,
cursor
};
}
}
@@ -0,0 +1,67 @@
import type { DidResolver } from '@web5/dids';
import type { MethodHandler } from '../types/method-handler.js';
import type { EventListener, EventStream } from '../types/subscriptions.js';
import type { EventsSubscribeMessage, EventsSubscribeReply, MessageSubscriptionHandler } from '../types/events-types.js';
import { Events } from '../utils/events.js';
import { EventsSubscribe } from '../interfaces/events-subscribe.js';
import { FilterUtility } from '../utils/filter.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { authenticate, authorizeOwner } from '../core/auth.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
export class EventsSubscribeHandler implements MethodHandler {
constructor(
private didResolver: DidResolver,
private eventStream?: EventStream
) {}
public async handle({
tenant,
message,
subscriptionHandler
}: {
tenant: string;
message: EventsSubscribeMessage;
subscriptionHandler: MessageSubscriptionHandler;
}): Promise<EventsSubscribeReply> {
if (this.eventStream === undefined) {
return messageReplyFromError(new DwnError(
DwnErrorCode.EventsSubscribeEventStreamUnimplemented,
'Subscriptions are not supported'
), 501);
}
let eventsSubscribe: EventsSubscribe;
try {
eventsSubscribe = await EventsSubscribe.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
try {
await authenticate(message.authorization, this.didResolver);
await authorizeOwner(tenant, eventsSubscribe);
} catch (error) {
return messageReplyFromError(error, 401);
}
const { filters } = message.descriptor;
const eventsFilters = Events.convertFilters(filters);
const messageCid = await Message.getCid(message);
const listener: EventListener = (eventTenant, event, eventIndexes):void => {
if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, eventsFilters)) {
subscriptionHandler(event);
}
};
const subscription = await this.eventStream.subscribe(tenant, messageCid, listener);
return {
status: { code: 200, detail: 'OK' },
subscription,
};
}
}
@@ -0,0 +1,80 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { MessageStore } from '../types/message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { RecordsQueryReplyEntry } from '../types/records-types.js';
import type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from '../types/messages-types.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { MessagesGet } from '../interfaces/messages-get.js';
import { authenticate, authorizeOwner } from '../core/auth.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
type HandleArgs = { tenant: string, message: MessagesGetMessage };
export class MessagesGetHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore) {}
public async handle({ tenant, message }: HandleArgs): Promise<MessagesGetReply> {
let messagesGet: MessagesGet;
try {
messagesGet = await MessagesGet.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
try {
await authenticate(message.authorization, this.didResolver);
await authorizeOwner(tenant, messagesGet);
} catch (e) {
return messageReplyFromError(e, 401);
}
const promises: Promise<MessagesGetReplyEntry>[] = [];
const messageCids = new Set(message.descriptor.messageCids);
for (const messageCid of messageCids) {
const promise = this.messageStore.get(tenant, messageCid)
.then(message => {
return { messageCid, message };
})
.catch(_ => {
return { messageCid, message: undefined, error: `Failed to get message ${messageCid}` };
});
promises.push(promise);
}
const messages = await Promise.all(promises);
// for every message, include associated data as `encodedData` IF:
// * its a RecordsWrite
// * the data size is equal or smaller than the size threshold
for (const entry of messages) {
const { message } = entry;
if (!message) {
continue;
}
const { interface: messageInterface, method } = message.descriptor;
if (messageInterface !== DwnInterfaceName.Records || method !== DwnMethodName.Write) {
continue;
}
// RecordsWrite specific handling, if MessageStore has embedded `encodedData` return it with the entry.
// we store `encodedData` along with the message if the data is below a certain threshold.
const recordsWrite = message as RecordsQueryReplyEntry;
if (recordsWrite.encodedData !== undefined) {
entry.encodedData = recordsWrite.encodedData;
delete recordsWrite.encodedData;
}
}
return {
status : { code: 200, detail: 'OK' },
entries : messages
};
}
}
@@ -0,0 +1,112 @@
import type { DidResolver } from '@web5/dids';
import type { EventLog } from '../types/event-log.js';
import type { EventStream } from '../types/subscriptions.js';
import type { GenericMessageReply } from '../types/message-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { ProtocolsConfigureMessage } from '../types/protocols-types.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolsConfigure } from '../interfaces/protocols-configure.js';
import { authenticate, authorizeOwner } from '../core/auth.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class ProtocolsConfigureHandler implements MethodHandler {
constructor(
private didResolver: DidResolver,
private messageStore: MessageStore,
private eventLog: EventLog,
private eventStream?: EventStream
) { }
public async handle({
tenant,
message,
}: {tenant: string, message: ProtocolsConfigureMessage }): Promise<GenericMessageReply> {
let protocolsConfigure: ProtocolsConfigure;
try {
protocolsConfigure = await ProtocolsConfigure.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
// authentication & authorization
try {
await authenticate(message.authorization, this.didResolver);
await authorizeOwner(tenant, protocolsConfigure);
} catch (e) {
return messageReplyFromError(e, 401);
}
// attempt to get existing protocol
const query = {
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Configure,
protocol : message.descriptor.definition.protocol
};
const { messages: existingMessages } = await this.messageStore.query(tenant, [ query ]);
// find newest message, and if the incoming message is the newest
let newestMessage = await Message.getNewestMessage(existingMessages);
let incomingMessageIsNewest = false;
if (newestMessage === undefined || await Message.isNewer(message, newestMessage)) {
incomingMessageIsNewest = true;
newestMessage = message;
}
// write the incoming message to DB if incoming message is newest
let messageReply: GenericMessageReply;
if (incomingMessageIsNewest) {
const indexes = ProtocolsConfigureHandler.constructIndexes(protocolsConfigure);
await this.messageStore.put(tenant, message, indexes);
const messageCid = await Message.getCid(message);
await this.eventLog.append(tenant, messageCid, indexes);
// only emit if the event stream is set
if (this.eventStream !== undefined) {
this.eventStream.emit(tenant, { message }, indexes);
}
messageReply = {
status: { code: 202, detail: 'Accepted' }
};
} else {
messageReply = {
status: { code: 409, detail: 'Conflict' }
};
}
// delete all existing records that are smaller
const deletedMessageCids: string[] = [];
for (const message of existingMessages) {
if (await Message.isNewer(newestMessage, message)) {
const messageCid = await Message.getCid(message);
deletedMessageCids.push(messageCid);
await this.messageStore.delete(tenant, messageCid);
}
}
await this.eventLog.deleteEventsByCid(tenant, deletedMessageCids);
return messageReply;
};
static constructIndexes(protocolsConfigure: ProtocolsConfigure): { [key: string]: string | boolean } {
// strip out `definition` as it is not indexable
const { definition, ...propertiesToIndex } = protocolsConfigure.message.descriptor;
const { author } = protocolsConfigure;
const indexes: { [key: string]: string | boolean } = {
...propertiesToIndex,
author : author!,
protocol : definition.protocol, // retain protocol url from `definition`,
published : definition.published // retain published state from definition
};
return indexes;
}
}
@@ -0,0 +1,80 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from '../types/protocols-types.js';
import { authenticate } from '../core/auth.js';
import { DwnErrorCode } from '../core/dwn-error.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolsQuery } from '../interfaces/protocols-query.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class ProtocolsQueryHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private messageStore: MessageStore,private dataStore: DataStore) { }
public async handle({
tenant,
message
}: { tenant: string, message: ProtocolsQueryMessage}): Promise<ProtocolsQueryReply> {
let protocolsQuery: ProtocolsQuery;
try {
protocolsQuery = await ProtocolsQuery.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
// authentication & authorization
try {
await authenticate(message.authorization, this.didResolver);
await protocolsQuery.authorize(tenant, this.messageStore);
} catch (error: any) {
// return public ProtocolsConfigures if query fails with a certain authentication or authorization code
if (error.code === DwnErrorCode.AuthenticateJwsMissing || // unauthenticated
error.code === DwnErrorCode.ProtocolsQueryUnauthorized) {
const entries: ProtocolsConfigureMessage[] = await this.fetchPublishedProtocolsConfigure(tenant, protocolsQuery);
return {
status: { code: 200, detail: 'OK' },
entries
};
} else {
return messageReplyFromError(error, 401);
}
}
const query = {
...message.descriptor.filter,
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Configure
};
removeUndefinedProperties(query);
const { messages } = await this.messageStore.query(tenant, [ query ]);
return {
status : { code: 200, detail: 'OK' },
entries : messages as ProtocolsConfigureMessage[]
};
};
/**
* Fetches only published `ProtocolsConfigure`.
*/
private async fetchPublishedProtocolsConfigure(tenant: string, protocolsQuery: ProtocolsQuery): Promise<ProtocolsConfigureMessage[]> {
// fetch all published `ProtocolConfigure` matching the query
const filter = {
...protocolsQuery.message.descriptor.filter,
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Configure,
published : true
};
const { messages: publishedProtocolsConfigure } = await this.messageStore.query(tenant, [ filter ]);
return publishedProtocolsConfigure as ProtocolsConfigureMessage[];
}
}
@@ -0,0 +1,146 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { EventLog } from '../types/event-log.js';
import type { EventStream } from '../types/subscriptions.js';
import type { GenericMessageReply } from '../types/message-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js';
import { authenticate } from '../core/auth.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { RecordsDelete } from '../interfaces/records-delete.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { StorageController } from '../store/storage-controller.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class RecordsDeleteHandler implements MethodHandler {
constructor(
private didResolver: DidResolver,
private messageStore: MessageStore,
private dataStore: DataStore,
private eventLog: EventLog,
private eventStream?: EventStream
) { }
public async handle({
tenant,
message
}: { tenant: string, message: RecordsDeleteMessage}): Promise<GenericMessageReply> {
let recordsDelete: RecordsDelete;
try {
recordsDelete = await RecordsDelete.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
// authentication
try {
await authenticate(message.authorization, this.didResolver);
} catch (e) {
return messageReplyFromError(e, 401);
}
// get existing records matching the `recordId`
const query = {
interface : DwnInterfaceName.Records,
recordId : message.descriptor.recordId
};
const { messages: existingMessages } = await this.messageStore.query(tenant, [ query ]);
// find which message is the newest, and if the incoming message is the newest
const newestExistingMessage = await Message.getNewestMessage(existingMessages);
let incomingMessageIsNewest = false;
let newestMessage;
// if incoming message is newest
if (newestExistingMessage === undefined || await Message.isNewer(message, newestExistingMessage)) {
incomingMessageIsNewest = true;
newestMessage = message;
} else { // existing message is the same age or newer than the incoming message
newestMessage = newestExistingMessage;
}
if (!incomingMessageIsNewest) {
return {
status: { code: 409, detail: 'Conflict' }
};
}
// return Not Found if record does not exist or is already deleted
if (newestExistingMessage === undefined || newestExistingMessage.descriptor.method === DwnMethodName.Delete) {
return {
status: { code: 404, detail: 'Not Found' }
};
}
// authorization
try {
await RecordsDeleteHandler.authorizeRecordsDelete(
tenant,
recordsDelete,
await RecordsWrite.parse(newestExistingMessage as RecordsWriteMessage),
this.messageStore
);
} catch (e) {
return messageReplyFromError(e, 401);
}
const initialWrite = await RecordsWrite.getInitialWrite(existingMessages);
const indexes = recordsDelete.constructIndexes(initialWrite);
const messageCid = await Message.getCid(message);
await this.messageStore.put(tenant, message, indexes);
await this.eventLog.append(tenant, messageCid, indexes);
// only emit if the event stream is set
if (this.eventStream !== undefined) {
this.eventStream.emit(tenant, { message, initialWrite }, indexes);
}
if (message.descriptor.prune) {
// purge/hard-delete all descendent records
await StorageController.purgeRecordDescendants(tenant, message.descriptor.recordId, this.messageStore, this.dataStore, this.eventLog);
}
// delete all existing messages that are not newest, except for the initial write
await StorageController.deleteAllOlderMessagesButKeepInitialWrite(
tenant, existingMessages, newestMessage, this.messageStore, this.dataStore, this.eventLog
);
const messageReply = {
status: { code: 202, detail: 'Accepted' }
};
return messageReply;
};
/**
* Authorizes a RecordsDelete message.
*
* @param newestRecordsWrite Newest RecordsWrite of the record to be deleted.
*/
private static async authorizeRecordsDelete(
tenant: string,
recordsDelete: RecordsDelete,
newestRecordsWrite: RecordsWrite,
messageStore: MessageStore
): Promise<void> {
if (Message.isSignedByAuthorDelegate(recordsDelete.message)) {
await recordsDelete.authorizeDelegate(newestRecordsWrite.message, messageStore);
}
if (recordsDelete.author === tenant) {
return;
} else if (newestRecordsWrite.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeDelete(tenant, recordsDelete, newestRecordsWrite, messageStore);
} else {
throw new DwnError(
DwnErrorCode.RecordsDeleteAuthorizationFailed,
'RecordsDelete message failed authorization'
);
}
}
};
@@ -0,0 +1,261 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { Filter, PaginationCursor } from '../types/query-types.js';
import type { GenericMessage, MessageSort } from '../types/message-types.js';
import type { RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry } from '../types/records-types.js';
import { authenticate } from '../core/auth.js';
import { DateSort } from '../types/records-types.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { Records } from '../utils/records.js';
import { RecordsQuery } from '../interfaces/records-query.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { SortDirection } from '../types/query-types.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class RecordsQueryHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore) { }
public async handle({
tenant,
message
}: {tenant: string, message: RecordsQueryMessage}): Promise<RecordsQueryReply> {
let recordsQuery: RecordsQuery;
try {
recordsQuery = await RecordsQuery.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
let recordsWrites: RecordsQueryReplyEntry[];
let cursor: PaginationCursor | undefined;
// if this is an anonymous query and the filter supports published records, query only published records
if (Records.filterIncludesPublishedRecords(recordsQuery.message.descriptor.filter) && recordsQuery.author === undefined) {
const results = await this.fetchPublishedRecords(tenant, recordsQuery);
recordsWrites = results.messages as RecordsQueryReplyEntry[];
cursor = results.cursor;
} else {
// authentication and authorization
try {
await authenticate(message.authorization!, this.didResolver);
await RecordsQueryHandler.authorizeRecordsQuery(tenant, recordsQuery, this.messageStore);
} catch (e) {
return messageReplyFromError(e, 401);
}
if (recordsQuery.author === tenant) {
const results = await this.fetchRecordsAsOwner(tenant, recordsQuery);
recordsWrites = results.messages as RecordsQueryReplyEntry[];
cursor = results.cursor;
} else {
const results = await this.fetchRecordsAsNonOwner(tenant, recordsQuery);
recordsWrites = results.messages as RecordsQueryReplyEntry[];
cursor = results.cursor;
}
}
// attach initial write if returned RecordsWrite is not initial write
for (const recordsWrite of recordsWrites) {
if (!await RecordsWrite.isInitialWrite(recordsWrite)) {
const initialWriteQueryResult = await this.messageStore.query(
tenant,
[{ recordId: recordsWrite.recordId, isLatestBaseState: false, method: DwnMethodName.Write }]
);
const initialWrite = initialWriteQueryResult.messages[0] as RecordsQueryReplyEntry;
delete initialWrite.encodedData; // defensive measure but technically optional because we do this when an update RecordsWrite takes place
recordsWrite.initialWrite = initialWrite;
}
}
return {
status : { code: 200, detail: 'OK' },
entries : recordsWrites,
cursor
};
}
/**
* Convert an incoming DateSort to a sort type accepted by MessageStore
* Defaults to 'dateCreated' in Descending order if no sort is supplied.
*
* @param dateSort the optional DateSort from the RecordsQuery message descriptor.
* @returns {MessageSort} for MessageStore sorting.
*/
private convertDateSort(dateSort?: DateSort): MessageSort {
switch (dateSort) {
case DateSort.CreatedAscending:
return { dateCreated: SortDirection.Ascending };
case DateSort.CreatedDescending:
return { dateCreated: SortDirection.Descending };
case DateSort.PublishedAscending:
return { datePublished: SortDirection.Ascending };
case DateSort.PublishedDescending:
return { datePublished: SortDirection.Descending };
default:
return { dateCreated: SortDirection.Ascending };
}
}
/**
* Fetches the records as the owner of the DWN with no additional filtering.
*/
private async fetchRecordsAsOwner(
tenant: string,
recordsQuery: RecordsQuery
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor }> {
const { dateSort, filter, pagination } = recordsQuery.message.descriptor;
// fetch all published records matching the query
const queryFilter = {
...Records.convertFilter(filter, dateSort),
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true
};
const messageSort = this.convertDateSort(dateSort);
return this.messageStore.query(tenant, [ queryFilter ], messageSort, pagination);
}
/**
* Fetches the records as a non-owner.
*
* Filters can support returning both published and unpublished records,
* as well as explicitly only published or only unpublished records.
*
* A) BOTH published and unpublished:
* 1. published records; and
* 2. unpublished records intended for the query author (where `recipient` is the query author); and
* 3. unpublished records authorized by a protocol rule.
*
* B) PUBLISHED:
* 1. only published records;
*
* C) UNPUBLISHED:
* 1. unpublished records intended for the query author (where `recipient` is the query author); and
* 2. unpublished records authorized by a protocol rule.
*
*/
private async fetchRecordsAsNonOwner(
tenant: string, recordsQuery: RecordsQuery
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor }> {
const { dateSort, pagination, filter } = recordsQuery.message.descriptor;
const filters = [];
if (Records.filterIncludesPublishedRecords(filter)) {
filters.push(RecordsQueryHandler.buildPublishedRecordsFilter(recordsQuery));
}
if (Records.filterIncludesUnpublishedRecords(filter)) {
filters.push(RecordsQueryHandler.buildUnpublishedRecordsByQueryAuthorFilter(recordsQuery));
const recipientFilter = recordsQuery.message.descriptor.filter.recipient;
if (recipientFilter === undefined || recipientFilter === recordsQuery.author) {
filters.push(RecordsQueryHandler.buildUnpublishedRecordsForQueryAuthorFilter(recordsQuery));
}
if (Records.shouldProtocolAuthorize(recordsQuery.signaturePayload!)) {
filters.push(RecordsQueryHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsQuery));
}
}
const messageSort = this.convertDateSort(dateSort);
return this.messageStore.query(tenant, filters, messageSort, pagination );
}
/**
* Fetches only published records.
*/
private async fetchPublishedRecords(
tenant: string, recordsQuery: RecordsQuery
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor }> {
const { dateSort, pagination } = recordsQuery.message.descriptor;
const filter = RecordsQueryHandler.buildPublishedRecordsFilter(recordsQuery);
const messageSort = this.convertDateSort(dateSort);
return this.messageStore.query(tenant, [ filter ], messageSort, pagination);
}
private static buildPublishedRecordsFilter(recordsQuery: RecordsQuery): Filter {
const { dateSort, filter } = recordsQuery.message.descriptor;
// fetch all published records matching the query
return {
...Records.convertFilter(filter, dateSort),
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
published : true,
isLatestBaseState : true
};
}
/**
* Creates a filter for unpublished records that are intended for the query author (where `recipient` is the author).
*/
private static buildUnpublishedRecordsForQueryAuthorFilter(recordsQuery: RecordsQuery): Filter {
const { dateSort, filter } = recordsQuery.message.descriptor;
// include records where recipient is query author
return {
...Records.convertFilter(filter, dateSort),
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
recipient : recordsQuery.author!,
isLatestBaseState : true,
published : false
};
}
/**
* Creates a filter for unpublished records that are within the specified protocol.
* Validation that `protocol` and other required protocol-related fields occurs before this method.
*/
private static buildUnpublishedProtocolAuthorizedRecordsFilter(recordsQuery: RecordsQuery): Filter {
const { dateSort, filter } = recordsQuery.message.descriptor;
return {
...Records.convertFilter(filter, dateSort),
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true,
published : false
};
}
/**
* Creates a filter for only unpublished records where the author is the same as the query author.
*/
private static buildUnpublishedRecordsByQueryAuthorFilter(recordsQuery: RecordsQuery): Filter {
const { dateSort, filter } = recordsQuery.message.descriptor;
// include records where author is the same as the query author
return {
...Records.convertFilter(filter, dateSort),
author : recordsQuery.author!,
interface : DwnInterfaceName.Records,
method : DwnMethodName.Write,
isLatestBaseState : true,
published : false
};
}
/**
* @param messageStore Used to check if the grant has been revoked.
*/
private static async authorizeRecordsQuery(
tenant: string,
recordsQuery: RecordsQuery,
messageStore: MessageStore
): Promise<void> {
if (Message.isSignedByAuthorDelegate(recordsQuery.message)) {
await recordsQuery.authorizeDelegate(messageStore);
}
// NOTE: not all RecordsQuery messages require protocol authorization even if the filter includes protocol-related fields,
// this is because we dynamically filter out records that the caller is not authorized to see.
// Currently only run protocol authorization if message deliberately invokes a protocol role.
if (Records.shouldProtocolAuthorize(recordsQuery.signaturePayload!)) {
await ProtocolAuthorization.authorizeQueryOrSubscribe(tenant, recordsQuery, messageStore);
}
}
}
@@ -0,0 +1,151 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { Filter } from '../types/query-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { RecordsQueryReplyEntry, RecordsReadMessage, RecordsReadReply } from '../types/records-types.js';
import { authenticate } from '../core/auth.js';
import { DataStream } from '../utils/data-stream.js';
import { Encoder } from '../utils/encoder.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { PermissionsProtocol } from '../protocols/permissions.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { RecordsRead } from '../interfaces/records-read.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class RecordsReadHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private messageStore: MessageStore, private dataStore: DataStore) { }
public async handle({
tenant,
message
}: { tenant: string, message: RecordsReadMessage }): Promise<RecordsReadReply> {
let recordsRead: RecordsRead;
try {
recordsRead = await RecordsRead.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
// authentication
try {
if (recordsRead.author !== undefined) {
await authenticate(message.authorization!, this.didResolver);
}
} catch (e) {
return messageReplyFromError(e, 401);
}
// get the latest active messages matching the supplied filter
// only RecordsWrite messages will be returned due to 'isLatestBaseState' being set to true.
const query: Filter = {
interface : DwnInterfaceName.Records,
isLatestBaseState : true,
...Records.convertFilter(message.descriptor.filter)
};
const { messages: existingMessages } = await this.messageStore.query(tenant, [ query ]);
if (existingMessages.length === 0) {
return {
status: { code: 404, detail: 'Not Found' }
};
} else if (existingMessages.length > 1) {
return messageReplyFromError(new DwnError(
DwnErrorCode.RecordsReadReturnedMultiple,
'Multiple records exist for the RecordsRead filter'
), 400);
}
const matchedRecordsWrite = existingMessages[0] as RecordsQueryReplyEntry;
try {
await RecordsReadHandler.authorizeRecordsRead(tenant, recordsRead, await RecordsWrite.parse(matchedRecordsWrite), this.messageStore);
} catch (error) {
return messageReplyFromError(error, 401);
}
let data;
if (matchedRecordsWrite.encodedData !== undefined) {
const dataBytes = Encoder.base64UrlToBytes(matchedRecordsWrite.encodedData);
data = DataStream.fromBytes(dataBytes);
delete matchedRecordsWrite.encodedData;
} else {
const result = await this.dataStore.get(tenant, matchedRecordsWrite.recordId, matchedRecordsWrite.descriptor.dataCid);
if (result?.dataStream === undefined) {
return {
status: { code: 404, detail: 'Not Found' }
};
}
data = result.dataStream;
}
const record = {
...matchedRecordsWrite,
data
};
// attach initial write if returned RecordsWrite is not initial write
if (!await RecordsWrite.isInitialWrite(record)) {
const initialWriteQueryResult = await this.messageStore.query(
tenant,
[{ recordId: record.recordId, isLatestBaseState: false, method: DwnMethodName.Write }]
);
const initialWrite = initialWriteQueryResult.messages[0] as RecordsQueryReplyEntry;
delete initialWrite.encodedData; // defensive measure but technically optional because we do this when an update RecordsWrite takes place
record.initialWrite = initialWrite;
}
const messageReply: RecordsReadReply = {
status: { code: 200, detail: 'OK' },
record
};
return messageReply;
};
/**
* @param messageStore Used to check if the grant has been revoked.
*/
private static async authorizeRecordsRead(
tenant: string,
recordsRead: RecordsRead,
matchedRecordsWrite: RecordsWrite,
messageStore: MessageStore
): Promise<void> {
if (Message.isSignedByAuthorDelegate(recordsRead.message)) {
await recordsRead.authorizeDelegate(matchedRecordsWrite.message, messageStore);
}
const { descriptor } = matchedRecordsWrite.message;
// if author is the same as the target tenant, we can directly grant access
if (recordsRead.author === tenant) {
return;
} else if (descriptor.published === true) {
// authentication is not required for published data
return;
} else if (recordsRead.author !== undefined && recordsRead.author === descriptor.recipient) {
// The recipient of a message may always read it
return;
} else if (recordsRead.author !== undefined && recordsRead.signaturePayload!.permissionGrantId !== undefined) {
const permissionGrant = await PermissionsProtocol.fetchGrant(tenant, messageStore, recordsRead.signaturePayload!.permissionGrantId);
await RecordsGrantAuthorization.authorizeRead({
recordsReadMessage : recordsRead.message,
recordsWriteMessageToBeRead : matchedRecordsWrite.message,
expectedGrantor : tenant,
expectedGrantee : recordsRead.author,
permissionGrant,
messageStore
});
} else if (descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeRead(tenant, recordsRead, matchedRecordsWrite, messageStore);
} else {
throw new DwnError(DwnErrorCode.RecordsReadAuthorizationFailed, 'message failed authorization');
}
}
}
@@ -0,0 +1,217 @@
import type { DidResolver } from '@web5/dids';
import type { Filter } from '../types/query-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { EventListener, EventStream } from '../types/subscriptions.js';
import type { RecordEvent, RecordsSubscribeMessage, RecordsSubscribeReply, RecordSubscriptionHandler } from '../types/records-types.js';
import { authenticate } from '../core/auth.js';
import { FilterUtility } from '../utils/filter.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { Records } from '../utils/records.js';
import { RecordsSubscribe } from '../interfaces/records-subscribe.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export class RecordsSubscribeHandler implements MethodHandler {
constructor(private didResolver: DidResolver, private messageStore: MessageStore, private eventStream?: EventStream) { }
public async handle({
tenant,
message,
subscriptionHandler
}: {
tenant: string,
message: RecordsSubscribeMessage,
subscriptionHandler: RecordSubscriptionHandler,
}): Promise<RecordsSubscribeReply> {
if (this.eventStream === undefined) {
return messageReplyFromError(new DwnError(
DwnErrorCode.RecordsSubscribeEventStreamUnimplemented,
'Subscriptions are not supported'
), 501);
}
let recordsSubscribe: RecordsSubscribe;
try {
recordsSubscribe = await RecordsSubscribe.parse(message);
} catch (e) {
return messageReplyFromError(e, 400);
}
let filters:Filter[] = [];
// if this is an anonymous subscribe and the filter supports published records, subscribe to only published records
if (Records.filterIncludesPublishedRecords(recordsSubscribe.message.descriptor.filter) && recordsSubscribe.author === undefined) {
// build filters for a stream of published records
filters = [ RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe) ];
// delete the undefined authorization property else the code will encounter the following IPLD issue when attempting to generate CID:
// Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded
delete message.authorization;
} else {
// authentication and authorization
try {
await authenticate(message.authorization!, this.didResolver);
await RecordsSubscribeHandler.authorizeRecordsSubscribe(tenant, recordsSubscribe, this.messageStore);
} catch (error) {
return messageReplyFromError(error, 401);
}
if (recordsSubscribe.author === tenant) {
// if the subscribe author is the tenant, filter as owner.
filters = await RecordsSubscribeHandler.filterAsOwner(recordsSubscribe);
} else {
// otherwise build filters based on published records, permissions, or protocol rules
filters = await RecordsSubscribeHandler.filterAsNonOwner(recordsSubscribe);
}
}
const listener: EventListener = (eventTenant, event, eventIndexes):void => {
if (tenant === eventTenant && FilterUtility.matchAnyFilter(eventIndexes, filters)) {
// the filters check for interface and method
// if matched the message is either a `RecordsWriteMessage` or `RecordsDeleteMessage` so we cast the event to a `RecordEvent`
subscriptionHandler(event as RecordEvent);
}
};
const messageCid = await Message.getCid(message);
const subscription = await this.eventStream.subscribe(tenant, messageCid, listener);
return {
status: { code: 200, detail: 'OK' },
subscription
};
}
/**
* Subscribe to records as the owner of the DWN with no additional filtering.
*/
private static async filterAsOwner(RecordsSubscribe: RecordsSubscribe): Promise<Filter[]> {
const { filter } = RecordsSubscribe.message.descriptor;
const subscribeFilter = {
...Records.convertFilter(filter),
interface : DwnInterfaceName.Records,
method : [ DwnMethodName.Write, DwnMethodName.Delete ], // we filter for both write and delete so that subscriber can update state.
};
return [ subscribeFilter ];
}
/**
* Creates filters in order to subscribe to records as a non-owner.
*
* Filters can support emitting messages for both published and unpublished records,
* as well as explicitly only published or only unpublished records.
*
* A) BOTH published and unpublished:
* 1. published records; and
* 2. unpublished records intended for the subscription author (where `recipient` is the subscription author); and
* 3. unpublished records authorized by a protocol rule.
*
* B) PUBLISHED:
* 1. only published records;
*
* C) UNPUBLISHED:
* 1. unpublished records intended for the subscription author (where `recipient` is the subscription author); and
* 2. unpublished records authorized by a protocol rule.
*/
private static async filterAsNonOwner(
recordsSubscribe: RecordsSubscribe
): Promise<Filter[]> {
const filters:Filter[] = [];
const { filter } = recordsSubscribe.message.descriptor;
if (Records.filterIncludesPublishedRecords(filter)) {
filters.push(RecordsSubscribeHandler.buildPublishedRecordsFilter(recordsSubscribe));
}
if (Records.filterIncludesUnpublishedRecords(filter)) {
filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe));
const recipientFilter = recordsSubscribe.message.descriptor.filter.recipient;
if (recipientFilter === undefined || recipientFilter === recordsSubscribe.author) {
filters.push(RecordsSubscribeHandler.buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe));
}
if (Records.shouldProtocolAuthorize(recordsSubscribe.signaturePayload!)) {
filters.push(RecordsSubscribeHandler.buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe));
}
}
return filters;
}
/**
* Creates a filter for all published records matching the subscribe
*/
private static buildPublishedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter {
return {
...Records.convertFilter(recordsSubscribe.message.descriptor.filter),
interface : DwnInterfaceName.Records,
method : [ DwnMethodName.Write, DwnMethodName.Delete ],
published : true,
};
}
/**
* Creates a filter for unpublished records that are intended for the subscribe author (where `recipient` is the author).
*/
private static buildUnpublishedRecordsForSubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter {
// include records where recipient is subscribe author
return {
...Records.convertFilter(recordsSubscribe.message.descriptor.filter),
interface : DwnInterfaceName.Records,
method : [ DwnMethodName.Write, DwnMethodName.Delete ],
recipient : recordsSubscribe.author!,
published : false
};
}
/**
* Creates a filter for unpublished records that are within the specified protocol.
* Validation that `protocol` and other required protocol-related fields occurs before this method.
*/
private static buildUnpublishedProtocolAuthorizedRecordsFilter(recordsSubscribe: RecordsSubscribe): Filter {
return {
...Records.convertFilter(recordsSubscribe.message.descriptor.filter),
interface : DwnInterfaceName.Records,
method : [ DwnMethodName.Write, DwnMethodName.Delete ],
published : false
};
}
/**
* Creates a filter for only unpublished records where the author is the same as the subscribe author.
*/
private static buildUnpublishedRecordsBySubscribeAuthorFilter(recordsSubscribe: RecordsSubscribe): Filter {
// include records where author is the same as the subscribe author
return {
...Records.convertFilter(recordsSubscribe.message.descriptor.filter),
author : recordsSubscribe.author!,
interface : DwnInterfaceName.Records,
method : [ DwnMethodName.Write, DwnMethodName.Delete ],
published : false
};
}
/**
* @param messageStore Used to check if the grant has been revoked.
*/
public static async authorizeRecordsSubscribe(
tenant: string,
recordsSubscribe: RecordsSubscribe,
messageStore: MessageStore
): Promise<void> {
if (Message.isSignedByAuthorDelegate(recordsSubscribe.message)) {
await recordsSubscribe.authorizeDelegate(messageStore);
}
// NOTE: not all RecordsSubscribe messages require protocol authorization even if the filter includes protocol-related fields,
// this is because we dynamically filter out records that the caller is not authorized to see.
// Currently only run protocol authorization if message deliberately invokes a protocol role.
if (Records.shouldProtocolAuthorize(recordsSubscribe.signaturePayload!)) {
await ProtocolAuthorization.authorizeQueryOrSubscribe(tenant, recordsSubscribe, messageStore);
}
}
}
@@ -0,0 +1,366 @@
import type { DataStore } from '../types/data-store.js';
import type { DidResolver } from '@web5/dids';
import type { EventLog } from '../types/event-log.js';
import type { EventStream } from '../types/subscriptions.js';
import type { GenericMessageReply } from '../types/message-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { MethodHandler } from '../types/method-handler.js';
import type { RecordsQueryReplyEntry, RecordsWriteMessage } from '../types/records-types.js';
import { authenticate } from '../core/auth.js';
import { Cid } from '../utils/cid.js';
import { DataStream } from '../utils/data-stream.js';
import { DwnConstant } from '../core/dwn-constant.js';
import { Encoder } from '../utils/encoder.js';
import { Message } from '../core/message.js';
import { messageReplyFromError } from '../core/message-reply.js';
import { PermissionsProtocol } from '../protocols/permissions.js';
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { StorageController } from '../store/storage-controller.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
type HandlerArgs = { tenant: string, message: RecordsWriteMessage, dataStream?: _Readable.Readable};
export class RecordsWriteHandler implements MethodHandler {
constructor(
private didResolver: DidResolver,
private messageStore: MessageStore,
private dataStore: DataStore,
private eventLog: EventLog,
private eventStream?: EventStream
) { }
public async handle({
tenant,
message,
dataStream
}: HandlerArgs): Promise<GenericMessageReply> {
let recordsWrite: RecordsWrite;
try {
recordsWrite = await RecordsWrite.parse(message);
// Protocol-authorized record specific validation
if (message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.validateReferentialIntegrity(tenant, recordsWrite, this.messageStore);
}
} catch (e) {
return messageReplyFromError(e, 400);
}
// authentication & authorization
try {
await authenticate(message.authorization, this.didResolver);
await RecordsWriteHandler.authorizeRecordsWrite(tenant, recordsWrite, this.messageStore);
} catch (e) {
return messageReplyFromError(e, 401);
}
// get existing messages matching the `recordId`
const query = {
interface : DwnInterfaceName.Records,
recordId : message.recordId
};
const { messages: existingMessages } = await this.messageStore.query(tenant, [ query ]);
// if the incoming write is not the initial write, then it must not modify any immutable properties defined by the initial write
const newMessageIsInitialWrite = await recordsWrite.isInitialWrite();
let initialWrite: RecordsWriteMessage | undefined;
if (!newMessageIsInitialWrite) {
try {
initialWrite = await RecordsWrite.getInitialWrite(existingMessages);
RecordsWrite.verifyEqualityOfImmutableProperties(initialWrite, message);
} catch (e) {
return messageReplyFromError(e, 400);
}
}
const newestExistingMessage = await Message.getNewestMessage(existingMessages);
let incomingMessageIsNewest = false;
let newestMessage; // keep reference of newest message for pruning later
if (newestExistingMessage === undefined || await Message.isNewer(message, newestExistingMessage)) {
incomingMessageIsNewest = true;
newestMessage = message;
} else { // existing message is the same age or newer than the incoming message
newestMessage = newestExistingMessage;
}
if (!incomingMessageIsNewest) {
return {
status: { code: 409, detail: 'Conflict' }
};
}
try {
// NOTE: We allow isLatestBaseState to be true ONLY if the incoming message comes with data, or if the incoming message is NOT an initial write
// This would allow an initial write to be written to the DB without data, but having it not queryable,
// because query implementation filters on `isLatestBaseState` being `true`
// thus preventing a user's attempt to gain authorized access to data by referencing the dataCid of a private data in their initial writes,
// See: https://github.com/TBD54566975/dwn-sdk-js/issues/359 for more info
let isLatestBaseState = false;
let messageWithOptionalEncodedData = message as RecordsQueryReplyEntry;
if (dataStream !== undefined) {
messageWithOptionalEncodedData = await this.processMessageWithDataStream(tenant, message, dataStream);
isLatestBaseState = true;
} else {
// else data stream is NOT provided
if (newestExistingMessage?.descriptor.method === DwnMethodName.Delete) {
throw new DwnError(
DwnErrorCode.RecordsWriteMissingDataStream,
'No data stream was provided with the previous message being a delete'
);
}
// at this point we know that newestExistingMessage exists is not a Delete
// if the incoming message is not an initial write, and no dataStream is provided, we would allow it provided it passes validation
// processMessageWithoutDataStream() abstracts that logic
if (!newMessageIsInitialWrite) {
const newestExistingWrite = newestExistingMessage as RecordsQueryReplyEntry;
messageWithOptionalEncodedData = await this.processMessageWithoutDataStream(tenant, message, newestExistingWrite );
isLatestBaseState = true;
}
}
const indexes = await recordsWrite.constructIndexes(isLatestBaseState);
await this.messageStore.put(tenant, messageWithOptionalEncodedData, indexes);
await this.eventLog.append(tenant, await Message.getCid(message), indexes);
// NOTE: We only emit a `RecordsWrite` when the message is the latest base state.
// Because we allow a `RecordsWrite` which is not the latest state to be written, but not queried, we shouldn't emit it either.
// It will be emitted as a part of a subsequent next write, if it is the latest base state.
if (this.eventStream !== undefined && isLatestBaseState) {
this.eventStream.emit(tenant, { message, initialWrite }, indexes);
}
} catch (error) {
const e = error as any;
if (e.code !== undefined) {
if (e.code === DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious ||
e.code === DwnErrorCode.RecordsWriteMissingDataInPrevious ||
e.code === DwnErrorCode.RecordsWriteMissingDataStream ||
e.code === DwnErrorCode.RecordsWriteDataCidMismatch ||
e.code === DwnErrorCode.RecordsWriteDataSizeMismatch ||
e.code.startsWith('PermissionsProtocolValidate') ||
e.code.startsWith('SchemaValidator')) {
return messageReplyFromError(error, 400);
}
}
// else throw
throw error;
}
const messageReply = {
status: { code: 202, detail: 'Accepted' }
};
// delete all existing messages of the same record that are not newest, except for the initial write
await StorageController.deleteAllOlderMessagesButKeepInitialWrite(
tenant, existingMessages, newestMessage, this.messageStore, this.dataStore, this.eventLog
);
await this.postProcessingForCoreRecordsWrite(tenant, recordsWrite);
return messageReply;
};
private static validateSchemaForCoreRecordsWrite(recordsWriteMessage: RecordsWriteMessage, dataBytes: Uint8Array): void {
if (recordsWriteMessage.descriptor.protocol === PermissionsProtocol.uri) {
PermissionsProtocol.validateSchema(recordsWriteMessage, dataBytes);
}
}
/**
* Performs additional necessary tasks if the RecordsWrite handled is a core DWN RecordsWrite that need additional processing.
* For instance: a Permission revocation RecordsWrite.
*/
private async postProcessingForCoreRecordsWrite(tenant: string, recordsWrite: RecordsWrite): Promise<void> {
// If this message is a Permission revocation, we need to delete all grant-authorized messages with timestamp after revocation
// TODO: https://github.com/TBD54566975/dwn-sdk-js/issues/716
// This code is a direct copy and paste from the original PermissionsRevokeHandler (no longer exists),
// but it appears that there was no test for it and it does not look like the code worked:
// - not seeing `permissionGrantId` being an index
// - not seeing `this.dataStore` being called to delete actual data
// - test coverage is missing for the main delete logic
if (recordsWrite.message.descriptor.protocol === PermissionsProtocol.uri &&
recordsWrite.message.descriptor.protocolPath === PermissionsProtocol.revocationPath) {
const permissionGrantId = recordsWrite.message.descriptor.parentId!;
const grantAuthorizedMessagesQuery = {
permissionGrantId,
dateCreated: { gte: recordsWrite.message.descriptor.messageTimestamp },
};
const { messages: grantAuthorizedMessagesAfterRevoke } = await this.messageStore.query(tenant, [ grantAuthorizedMessagesQuery ]);
const grantAuthorizedMessageCidsAfterRevoke: string[] = [];
for (const grantAuthorizedMessage of grantAuthorizedMessagesAfterRevoke) {
const messageCid = await Message.getCid(grantAuthorizedMessage);
await this.messageStore.delete(tenant, messageCid);
}
this.eventLog.deleteEventsByCid(tenant, grantAuthorizedMessageCidsAfterRevoke);
}
}
/**
* Returns a `RecordsQueryReplyEntry` with a copy of the incoming message and the incoming data encoded to `Base64URL`.
*/
public async cloneAndAddEncodedData(message: RecordsWriteMessage, dataBytes: Uint8Array):Promise<RecordsQueryReplyEntry> {
const recordsWrite: RecordsQueryReplyEntry = { ...message };
recordsWrite.encodedData = Encoder.bytesToBase64Url(dataBytes);
return recordsWrite;
}
private async processMessageWithDataStream(
tenant: string,
message: RecordsWriteMessage,
dataStream: _Readable.Readable,
):Promise<RecordsQueryReplyEntry> {
let messageWithOptionalEncodedData: RecordsQueryReplyEntry = message;
// if data is below the threshold, we store it within MessageStore
if (message.descriptor.dataSize <= DwnConstant.maxDataSizeAllowedToBeEncoded) {
// validate data integrity before setting.
const dataBytes = await DataStream.toBytes(dataStream!);
const dataCid = await Cid.computeDagPbCidFromBytes(dataBytes);
RecordsWriteHandler.validateDataIntegrity(message.descriptor.dataCid, message.descriptor.dataSize, dataCid, dataBytes.length);
RecordsWriteHandler.validateSchemaForCoreRecordsWrite(message, dataBytes);
messageWithOptionalEncodedData = await this.cloneAndAddEncodedData(message, dataBytes);
} else {
// split the dataStream into two: one for CID computation and one for storage
const [dataStreamCopy1, dataStreamCopy2] = DataStream.duplicateDataStream(dataStream, 2);
try {
// perform storage and CID computation in parallel
const [dataCid, DataStorePutResult] = await Promise.all([
Cid.computeDagPbCidFromStream(dataStreamCopy1),
this.dataStore.put(tenant, message.recordId, message.descriptor.dataCid, dataStreamCopy2)
]);
RecordsWriteHandler.validateDataIntegrity(message.descriptor.dataCid, message.descriptor.dataSize, dataCid, DataStorePutResult.dataSize);
} catch (error) {
// unwind/delete data if we have issue with storage or the data failed integrity validation
await this.dataStore.delete(tenant, message.recordId, message.descriptor.dataCid);
throw error;
}
}
return messageWithOptionalEncodedData;
}
private async processMessageWithoutDataStream(
tenant: string,
message: RecordsWriteMessage,
newestExistingWrite: RecordsQueryReplyEntry,
):Promise<RecordsQueryReplyEntry> {
const messageWithOptionalEncodedData: RecordsQueryReplyEntry = { ...message }; // clone
const { dataCid, dataSize } = message.descriptor;
// Since incoming message is not an initial write, and no dataStream is provided, we first check integrity against newest existing write.
// we preform the dataCid check in case a user attempts to gain access to data by referencing a different known dataCid,
// so we insure that the data is already associated with the existing newest message
// See: https://github.com/TBD54566975/dwn-sdk-js/issues/359 for more info
RecordsWriteHandler.validateDataIntegrity(dataCid, dataSize, newestExistingWrite.descriptor.dataCid, newestExistingWrite.descriptor.dataSize);
if (dataSize <= DwnConstant.maxDataSizeAllowedToBeEncoded) {
// we encode the data from the original write if it is smaller than the data-store threshold
if (newestExistingWrite.encodedData !== undefined) {
messageWithOptionalEncodedData.encodedData = newestExistingWrite.encodedData;
} else {
throw new DwnError(
DwnErrorCode.RecordsWriteMissingEncodedDataInPrevious,
`No dataStream was provided and unable to get data from previous message`
);
}
} else {
// else just make sure the data is in the data store
// attempt to retrieve the data from the previous message
const DataStoreGetResult = await this.dataStore.get(tenant, newestExistingWrite.recordId, message.descriptor.dataCid);
if (DataStoreGetResult === undefined) {
throw new DwnError(
DwnErrorCode.RecordsWriteMissingDataInPrevious,
`No dataStream was provided and unable to get data from previous message`
);
}
}
return messageWithOptionalEncodedData;
}
/**
* Validates the expected `dataCid` and `dataSize` in the descriptor vs the received data.
*
* @throws {DwnError} with `DwnErrorCode.RecordsWriteDataCidMismatch`
* if the data stream resulted in a data CID that mismatches with `dataCid` in the given message
* @throws {DwnError} with `DwnErrorCode.RecordsWriteDataSizeMismatch`
* if `dataSize` in `descriptor` given mismatches the actual data size
*/
private static validateDataIntegrity(
expectedDataCid: string,
expectedDataSize: number,
actualDataCid: string,
actualDataSize: number
): void {
if (expectedDataCid !== actualDataCid) {
throw new DwnError(
DwnErrorCode.RecordsWriteDataCidMismatch,
`actual data CID ${actualDataCid} does not match dataCid in descriptor: ${expectedDataCid}`
);
}
if (expectedDataSize !== actualDataSize) {
throw new DwnError(
DwnErrorCode.RecordsWriteDataSizeMismatch,
`actual data size ${actualDataSize} bytes does not match dataSize in descriptor: ${expectedDataSize}`
);
}
}
private static async authorizeRecordsWrite(tenant: string, recordsWrite: RecordsWrite, messageStore: MessageStore): Promise<void> {
// if owner signature is given (`owner` is not `undefined`), it must be the same as the tenant DID
if (recordsWrite.owner !== undefined && recordsWrite.owner !== tenant) {
throw new DwnError(
DwnErrorCode.RecordsWriteOwnerAndTenantMismatch,
`Owner ${recordsWrite.owner} must be the same as tenant ${tenant} when specified.`
);
}
if (recordsWrite.isSignedByAuthorDelegate) {
await recordsWrite.authorizeAuthorDelegate(messageStore);
}
if (recordsWrite.isSignedByOwnerDelegate) {
await recordsWrite.authorizeOwnerDelegate(messageStore);
}
if (recordsWrite.owner !== undefined) {
// if incoming message is a write retained by this tenant, we by-design always allow
// NOTE: the "owner === tenant" check is already done earlier in this method
return;
} else if (recordsWrite.author === tenant) {
// if author is the same as the target tenant, we can directly grant access
return;
} else if (recordsWrite.author !== undefined && recordsWrite.signaturePayload!.permissionGrantId !== undefined) {
const permissionGrant = await PermissionsProtocol.fetchGrant(tenant, messageStore, recordsWrite.signaturePayload!.permissionGrantId);
await RecordsGrantAuthorization.authorizeWrite({
recordsWriteMessage : recordsWrite.message,
expectedGrantor : tenant,
expectedGrantee : recordsWrite.author,
permissionGrant,
messageStore
});
} else if (recordsWrite.message.descriptor.protocol !== undefined) {
await ProtocolAuthorization.authorizeWrite(tenant, recordsWrite, messageStore);
} else {
throw new DwnError(DwnErrorCode.RecordsWriteAuthorizationFailed, 'message failed authorization');
}
}
}
+59
View File
@@ -0,0 +1,59 @@
// export everything that we want to be consumable
export type { DwnConfig } from './dwn.js';
export type { EventLog } from './types/event-log.js';
export type { EventsGetMessage, EventsGetReply, EventsQueryMessage, EventsQueryReply, EventsSubscribeDescriptor, EventsSubscribeMessage, EventsSubscribeReply, MessageSubscriptionHandler as EventSubscriptionHandler } from './types/events-types.js';
export type { EventStream, MessageEvent, SubscriptionReply } from './types/subscriptions.js';
export type { GenericMessage, GenericMessageReply, MessageSort, MessageSubscription, Pagination, QueryResultEntry } from './types/message-types.js';
export type { MessagesGetMessage, MessagesGetReply, MessagesGetReplyEntry } from './types/messages-types.js';
export type { Filter, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions } from './types/query-types.js';
export type { PermissionConditions, PermissionScope } from './types/permission-types.js';
export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply } from './types/protocols-types.js';
export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadMessage, RecordsReadReply, RecordsSubscribeDescriptor, RecordsSubscribeMessage, RecordsSubscribeReply, RecordSubscriptionHandler, RecordsWriteDescriptor, RecordsWriteTags, RecordsWriteTagValue, RecordsWriteMessage } from './types/records-types.js';
export { authenticate } from './core/auth.js';
export { ActiveTenantCheckResult, AllowAllTenantGate, TenantGate } from './core/tenant-gate.js';
export { Cid } from './utils/cid.js';
export { RecordsQuery, RecordsQueryOptions } from './interfaces/records-query.js';
export { DataStore, DataStorePutResult, DataStoreGetResult } from './types/data-store.js';
export { DataStream } from './utils/data-stream.js';
export { DateSort } from './types/records-types.js';
export { DerivedPrivateJwk, HdKey, KeyDerivationScheme } from './utils/hd-key.js';
export { Dwn } from './dwn.js';
export { DwnConstant } from './core/dwn-constant.js';
export { DwnError, DwnErrorCode } from './core/dwn-error.js';
export { DwnInterfaceName, DwnMethodName } from './enums/dwn-interface-method.js';
export { Encoder } from './utils/encoder.js';
export { EventsGet, EventsGetOptions } from './interfaces/events-get.js';
export { EventsQuery, EventsQueryOptions } from './interfaces/events-query.js';
export { EventsSubscribe, EventsSubscribeOptions } from './interfaces/events-subscribe.js';
export { Encryption, EncryptionAlgorithm } from './utils/encryption.js';
export { EncryptionInput, KeyEncryptionInput, RecordsWrite, RecordsWriteOptions, CreateFromOptions } from './interfaces/records-write.js';
export { executeUnlessAborted } from './utils/abort.js';
export { Jws } from './utils/jws.js';
export { KeyMaterial, PrivateJwk, PublicJwk } from './types/jose-types.js';
export { Message } from './core/message.js';
export { MessagesGet, MessagesGetOptions } from './interfaces/messages-get.js';
export { UnionMessageReply } from './core/message-reply.js';
export { MessageStore, MessageStoreOptions } from './types/message-store.js';
export { PermissionsProtocol } from './protocols/permissions.js';
export { PrivateKeySigner } from './utils/private-key-signer.js';
export { Protocols } from './utils/protocols.js';
export { ProtocolsConfigure, ProtocolsConfigureOptions } from './interfaces/protocols-configure.js';
export { ProtocolsQuery, ProtocolsQueryOptions } from './interfaces/protocols-query.js';
export { Records } from './utils/records.js';
export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records-delete.js';
export { RecordsRead, RecordsReadOptions } from './interfaces/records-read.js';
export { RecordsSubscribe, RecordsSubscribeOptions } from './interfaces/records-subscribe.js';
export { Secp256k1 } from './utils/secp256k1.js';
export { Secp256r1 } from './utils/secp256r1.js';
export { Signer } from './types/signer.js';
export { SortDirection } from './types/query-types.js';
export { Time } from './utils/time.js';
// concrete implementations of stores and event stream
export { DataStoreLevel } from './store/data-store-level.js';
export { EventLogLevel } from './event-log/event-log-level.js';
export { MessageStoreLevel } from './store/message-store-level.js';
export { EventEmitterStream } from './event-log/event-emitter-stream.js';
// test library exports
export { Persona, TestDataGenerator } from '../tests/utils/test-data-generator.js';
@@ -0,0 +1,44 @@
import type { PaginationCursor } from '../types/query-types.js';
import type { Signer } from '../types/signer.js';
import type { EventsGetDescriptor, EventsGetMessage } from '../types/events-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Message } from '../core/message.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type EventsGetOptions = {
cursor?: PaginationCursor;
signer: Signer;
messageTimestamp?: string;
};
export class EventsGet extends AbstractMessage<EventsGetMessage> {
public static async parse(message: EventsGetMessage): Promise<EventsGet> {
Message.validateJsonSchema(message);
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new EventsGet(message);
}
public static async create(options: EventsGetOptions): Promise<EventsGet> {
const descriptor: EventsGetDescriptor = {
interface : DwnInterfaceName.Events,
method : DwnMethodName.Get,
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
};
if (options.cursor) {
descriptor.cursor = options.cursor;
}
const authorization = await Message.createAuthorization({ descriptor, signer: options.signer });
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
return new EventsGet(message);
}
}
@@ -0,0 +1,56 @@
import type { PaginationCursor } from '../types/query-types.js';
import type { Signer } from '../types/signer.js';
import type { EventsFilter, EventsQueryDescriptor, EventsQueryMessage } from '../types/events-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Events } from '../utils/events.js';
import { Message } from '../core/message.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
export type EventsQueryOptions = {
signer: Signer;
filters: EventsFilter[];
cursor?: PaginationCursor;
messageTimestamp?: string;
};
export class EventsQuery extends AbstractMessage<EventsQueryMessage>{
public static async parse(message: EventsQueryMessage): Promise<EventsQuery> {
Message.validateJsonSchema(message);
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
for (const filter of message.descriptor.filters) {
if ('protocol' in filter && filter.protocol !== undefined) {
validateProtocolUrlNormalized(filter.protocol);
}
if ('schema' in filter && filter.schema !== undefined) {
validateSchemaUrlNormalized(filter.schema);
}
}
return new EventsQuery(message);
}
public static async create(options: EventsQueryOptions): Promise<EventsQuery> {
const descriptor: EventsQueryDescriptor = {
interface : DwnInterfaceName.Events,
method : DwnMethodName.Query,
filters : Events.normalizeFilters(options.filters),
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
cursor : options.cursor,
};
removeUndefinedProperties(descriptor);
const authorization = await Message.createAuthorization({ descriptor, signer: options.signer });
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
return new EventsQuery(message);
}
}
@@ -0,0 +1,64 @@
import type { Signer } from '../types/signer.js';
import type { EventsFilter, EventsSubscribeDescriptor, EventsSubscribeMessage } from '../types/events-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Message } from '../core/message.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
export type EventsSubscribeOptions = {
signer: Signer;
messageTimestamp?: string;
filters?: EventsFilter[]
};
export class EventsSubscribe extends AbstractMessage<EventsSubscribeMessage> {
public static async parse(message: EventsSubscribeMessage): Promise<EventsSubscribe> {
Message.validateJsonSchema(message);
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
for (const filter of message.descriptor.filters) {
if ('protocol' in filter && filter.protocol !== undefined) {
validateProtocolUrlNormalized(filter.protocol);
}
if ('schema' in filter && filter.schema !== undefined) {
validateSchemaUrlNormalized(filter.schema);
}
}
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new EventsSubscribe(message);
}
/**
* Creates a EventsSubscribe message.
*
* @throws {DwnError} if json schema validation fails.
*/
public static async create(
options: EventsSubscribeOptions
): Promise<EventsSubscribe> {
const currentTime = Time.getCurrentTimestamp();
const descriptor: EventsSubscribeDescriptor = {
interface : DwnInterfaceName.Events,
method : DwnMethodName.Subscribe,
filters : options.filters ?? [],
messageTimestamp : options.messageTimestamp ?? currentTime,
};
removeUndefinedProperties(descriptor);
const authorization = await Message.createAuthorization({
descriptor,
signer: options.signer
});
const message: EventsSubscribeMessage = { descriptor, authorization };
Message.validateJsonSchema(message);
return new EventsSubscribe(message);
}
}
@@ -0,0 +1,59 @@
import type { Signer } from '../types/signer.js';
import type { MessagesGetDescriptor, MessagesGetMessage } from '../types/messages-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Cid } from '../utils/cid.js';
import { Message } from '../core/message.js';
import { Time } from '../utils/time.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type MessagesGetOptions = {
messageCids: string[];
signer: Signer;
messageTimestamp?: string;
};
export class MessagesGet extends AbstractMessage<MessagesGetMessage> {
public static async parse(message: MessagesGetMessage): Promise<MessagesGet> {
Message.validateJsonSchema(message);
this.validateMessageCids(message.descriptor.messageCids);
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new MessagesGet(message);
}
public static async create(options: MessagesGetOptions): Promise<MessagesGet> {
const descriptor: MessagesGetDescriptor = {
interface : DwnInterfaceName.Messages,
method : DwnMethodName.Get,
messageCids : options.messageCids,
messageTimestamp : options?.messageTimestamp ?? Time.getCurrentTimestamp(),
};
const authorization = await Message.createAuthorization({ descriptor, signer: options.signer });
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
MessagesGet.validateMessageCids(options.messageCids);
return new MessagesGet(message);
}
/**
* validates the provided cids
* @param messageCids - the cids in question
* @throws {DwnError} if an invalid cid is found.
*/
private static validateMessageCids(messageCids: string[]): void {
for (const cid of messageCids) {
try {
Cid.parseCid(cid);
} catch (_) {
throw new DwnError(DwnErrorCode.MessageGetInvalidCid, `${cid} is not a valid CID`);
}
}
}
}
@@ -0,0 +1,306 @@
import type { Signer } from '../types/signer.js';
import type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureDescriptor, ProtocolsConfigureMessage } from '../types/protocols-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import Ajv from 'ajv/dist/2020.js';
import { Message } from '../core/message.js';
import { Time } from '../utils/time.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { normalizeProtocolUrl, normalizeSchemaUrl, validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
import { ProtocolAction, ProtocolActor } from '../types/protocols-types.js';
export type ProtocolsConfigureOptions = {
messageTimestamp?: string;
definition: ProtocolDefinition;
signer: Signer;
permissionGrantId?: string;
};
export class ProtocolsConfigure extends AbstractMessage<ProtocolsConfigureMessage> {
public static async parse(message: ProtocolsConfigureMessage): Promise<ProtocolsConfigure> {
Message.validateJsonSchema(message);
ProtocolsConfigure.validateProtocolDefinition(message.descriptor.definition);
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new ProtocolsConfigure(message);
}
public static async create(options: ProtocolsConfigureOptions): Promise<ProtocolsConfigure> {
const descriptor: ProtocolsConfigureDescriptor = {
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Configure,
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
definition : ProtocolsConfigure.normalizeDefinition(options.definition)
};
const authorization = await Message.createAuthorization({
descriptor,
signer : options.signer,
permissionGrantId : options.permissionGrantId
});
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
ProtocolsConfigure.validateProtocolDefinition(message.descriptor.definition);
const protocolsConfigure = new ProtocolsConfigure(message);
return protocolsConfigure;
}
/**
* Performs validation on the given protocol definition that are not easy to do using a JSON schema.
*/
private static validateProtocolDefinition(definition: ProtocolDefinition): void {
const { protocol, types } = definition;
// validate protocol url
validateProtocolUrlNormalized(protocol);
// validate schema url normalized
for (const typeName in types) {
const schema = types[typeName].schema;
if (schema !== undefined) {
validateSchemaUrlNormalized(schema);
}
}
// validate `structure
ProtocolsConfigure.validateStructure(definition);
}
private static validateStructure(definition: ProtocolDefinition): void {
// gather all declared record types
const recordTypes = Object.keys(definition.types);
// gather all roles
const roles = ProtocolsConfigure.fetchAllRolePathsRecursively('', definition.structure, []);
// validate the entire rule set structure recursively
ProtocolsConfigure.validateRuleSetRecursively({
ruleSet : definition.structure,
ruleSetProtocolPath : '',
recordTypes,
roles
});
}
/**
* Parses the given rule set hierarchy to get all the role protocol paths.
* @throws DwnError if the hierarchy depth goes beyond 10 levels.
*/
private static fetchAllRolePathsRecursively(ruleSetProtocolPath: string, ruleSet: ProtocolRuleSet, roles: string[]): string[] {
// Limit the depth of the record hierarchy to 10 levels
// There is opportunity to optimize here to avoid repeated string splitting
if (ruleSetProtocolPath.split('/').length > 10) {
throw new DwnError(DwnErrorCode.ProtocolsConfigureRecordNestingDepthExceeded, 'Record nesting depth exceeded 10 levels.');
}
for (const recordType in ruleSet) {
// ignore non-nested-record properties
if (recordType.startsWith('$')) {
continue;
}
const childRuleSet = ruleSet[recordType];
let childRuleSetProtocolPath;
if (ruleSetProtocolPath === '') {
childRuleSetProtocolPath = recordType;
} else {
childRuleSetProtocolPath = `${ruleSetProtocolPath}/${recordType}`;
}
// if this is a role record, add it to the list, else continue to traverse
if (childRuleSet.$role) {
roles.push(childRuleSetProtocolPath);
} else {
ProtocolsConfigure.fetchAllRolePathsRecursively(childRuleSetProtocolPath, childRuleSet, roles);
}
}
return roles;
}
/**
* Validates the given rule set structure then recursively validates its nested child rule sets.
*/
private static validateRuleSetRecursively(
input: { ruleSet: ProtocolRuleSet, ruleSetProtocolPath: string, recordTypes: string[], roles: string[] }
): void {
const { ruleSet, ruleSetProtocolPath, recordTypes, roles } = input;
// Validate $actions in the rule set
if (ruleSet.$size !== undefined) {
const { min = 0, max } = ruleSet.$size;
if (max !== undefined && max < min) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidSize,
`Invalid size range found: max limit ${max} less than min limit ${min} at protocol path '${ruleSetProtocolPath}'`
);
}
}
if (ruleSet.$tags) {
const ajv = new Ajv.default();
const { $allowUndefinedTags, $requiredTags, ...tagProperties } = ruleSet.$tags;
// we validate each tag's expected schema to ensure it is a valid JSON schema
for (const tag in tagProperties) {
const tagSchemaDefinition = tagProperties[tag];
if (!ajv.validateSchema(tagSchemaDefinition)) {
const schemaError = ajv.errorsText(ajv.errors, { dataVar: `${ruleSetProtocolPath}/$tags/${tag}` });
throw new DwnError(DwnErrorCode.ProtocolsConfigureInvalidTagSchema, `tags schema validation error: ${schemaError}`);
}
}
}
// validate each action rule
const actionRules = ruleSet.$actions ?? [];
for (let i = 0; i < actionRules.length; i++) {
const actionRule = actionRules[i];
// Validate the `role` property of an `action` if exists.
if (actionRule.role !== undefined) {
// make sure the role contains a valid protocol paths to a role record
if (!roles.includes(actionRule.role)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureRoleDoesNotExistAtGivenPath,
`Role in action ${JSON.stringify(actionRule)} for rule set ${ruleSetProtocolPath} does not exist.`
);
}
}
// Validate that if `who` is set to `anyone` then `of` is not set
if (actionRule.who === 'anyone' && actionRule.of) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidActionOfNotAllowed,
`'of' is not allowed at rule set protocol path (${ruleSetProtocolPath})`
);
}
// Validate that if `who === recipient` and `of === undefined`, then `can` can only contain `co-update`, `co-delete`, and `co-prune`.
// We do not allow `read`, `write`, or `query` in the `can` array because:
// - `read` - Recipients are always allowed to `read`.
// - `write` - Entails ability to create and update.
// Since `of` is undefined, it implies the recipient of THIS record,
// there is no 'recipient' until this record has been created, so it makes no sense to allow recipient to write this record.
// - `query` - Only authorized using roles, so allowing direct recipients to query is outside the scope.
if (actionRule.who === ProtocolActor.Recipient && actionRule.of === undefined) {
// throw if `can` contains a value that is not `co-update`, `co-delete`, or `co-prune`
const hasDisallowedAction = actionRule.can.some(
action => ![ProtocolAction.CoUpdate, ProtocolAction.CoDelete, ProtocolAction.CoPrune].includes(action as ProtocolAction)
);
if (hasDisallowedAction) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidRecipientOfAction,
'Rules for `recipient` without `of` property must have `can` containing only `co-update`, `co-delete`, and `co-prune`.'
);
}
}
// Validate that if `who` is set to `author` then `of` is set
if (actionRule.who === ProtocolActor.Author && !actionRule.of) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidActionMissingOf,
`'of' is required when 'author' is specified as 'who'`
);
}
// validate that if `can` contains `update` or `delete`, it must also contain `create`
if (actionRule.can !== undefined) {
if (actionRule.can.includes(ProtocolAction.Update) && !actionRule.can.includes(ProtocolAction.Create)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidActionUpdateWithoutCreate,
`Action rule ${JSON.stringify(actionRule)} contains 'update' action but missing the required 'create' action.`
);
}
if (actionRule.can.includes(ProtocolAction.Delete) && !actionRule.can.includes(ProtocolAction.Create)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidActionDeleteWithoutCreate,
`Action rule ${JSON.stringify(actionRule)} contains 'delete' action but missing the required 'create' action.`
);
}
}
// Validate that there are no duplicate actors or roles in the remaining action rules:
// ie. no two action rules can have the same combination of `who` + `of` or `role`.
// NOTE: we only need to check the remaining action rules that have yet to go through action rule validation loop, as a perf shortcut.
for (let j = i + 1; j < actionRules.length; j++) {
const otherActionRule = actionRules[j];
if (actionRule.who !== undefined) {
if (actionRule.who === otherActionRule.who && actionRule.of === otherActionRule.of) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureDuplicateActorInRuleSet,
`More than one action rule per actor ${actionRule.who} of ${actionRule.of} not allowed within a rule set: ${JSON.stringify(actionRule)}`
);
}
} else {
// else implicitly a role-based action rule
if (actionRule.role === otherActionRule.role) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureDuplicateRoleInRuleSet,
`More than one action rule per role ${actionRule.role} not allowed within a rule set: ${JSON.stringify(actionRule)}`
);
}
}
}
}
// Validate nested rule sets
for (const recordType in ruleSet) {
if (recordType.startsWith('$')) {
continue;
}
if (!recordTypes.includes(recordType)) {
throw new DwnError(
DwnErrorCode.ProtocolsConfigureInvalidRuleSetRecordType,
`Rule set ${recordType} is not declared as an allowed type in the protocol definition.`
);
}
const childRuleSet = ruleSet[recordType];
let childRuleSetProtocolPath;
if (ruleSetProtocolPath === '') {
childRuleSetProtocolPath = recordType; // case of initial definition structure
} else {
childRuleSetProtocolPath = `${ruleSetProtocolPath}/${recordType}`;
}
ProtocolsConfigure.validateRuleSetRecursively({
ruleSet : childRuleSet,
ruleSetProtocolPath : childRuleSetProtocolPath,
recordTypes,
roles
});
}
}
private static normalizeDefinition(definition: ProtocolDefinition): ProtocolDefinition {
const typesCopy = { ...definition.types };
// Normalize schema url
for (const typeName in typesCopy) {
const schema = typesCopy[typeName].schema;
if (schema !== undefined) {
typesCopy[typeName].schema = normalizeSchemaUrl(schema);
}
}
return {
...definition,
protocol : normalizeProtocolUrl(definition.protocol),
types : typesCopy,
};
}
}
@@ -0,0 +1,97 @@
import type { AuthorizationModel } from '../types/message-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { Signer } from '../types/signer.js';
import type { ProtocolsQueryDescriptor, ProtocolsQueryFilter, ProtocolsQueryMessage } from '../types/protocols-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { GrantAuthorization } from '../core/grant-authorization.js';
import { Message } from '../core/message.js';
import { PermissionsProtocol } from '../protocols/permissions.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { normalizeProtocolUrl, validateProtocolUrlNormalized } from '../utils/url.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
export type ProtocolsQueryOptions = {
messageTimestamp?: string;
filter?: ProtocolsQueryFilter,
signer?: Signer;
permissionGrantId?: string;
};
export class ProtocolsQuery extends AbstractMessage<ProtocolsQueryMessage> {
public static async parse(message: ProtocolsQueryMessage): Promise<ProtocolsQuery> {
if (message.authorization !== undefined) {
await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
}
if (message.descriptor.filter !== undefined) {
validateProtocolUrlNormalized(message.descriptor.filter.protocol);
}
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new ProtocolsQuery(message);
}
public static async create(options: ProtocolsQueryOptions): Promise<ProtocolsQuery> {
const descriptor: ProtocolsQueryDescriptor = {
interface : DwnInterfaceName.Protocols,
method : DwnMethodName.Query,
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
filter : options.filter ? ProtocolsQuery.normalizeFilter(options.filter) : undefined,
};
// delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID:
// Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded
removeUndefinedProperties(descriptor);
// only generate the `authorization` property if signature input is given
let authorization: AuthorizationModel | undefined;
if (options.signer !== undefined) {
authorization = await Message.createAuthorization({
descriptor,
signer : options.signer,
permissionGrantId : options.permissionGrantId
});
}
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
const protocolsQuery = new ProtocolsQuery(message);
return protocolsQuery;
}
static normalizeFilter(filter: ProtocolsQueryFilter): ProtocolsQueryFilter {
return {
...filter,
protocol: normalizeProtocolUrl(filter.protocol),
};
}
public async authorize(tenant: string, messageStore: MessageStore): Promise<void> {
// if author is the same as the target tenant, we can directly grant access
if (this.author === tenant) {
return;
} else if (this.author !== undefined && this.signaturePayload!.permissionGrantId) {
const permissionGrant = await PermissionsProtocol.fetchGrant(tenant, messageStore, this.signaturePayload!.permissionGrantId);
await GrantAuthorization.performBaseValidation({
incomingMessage : this.message,
expectedGrantor : tenant,
expectedGrantee : this.author,
permissionGrant,
messageStore
});
} else {
throw new DwnError(
DwnErrorCode.ProtocolsQueryUnauthorized,
'The ProtocolsQuery failed authorization'
);
}
}
}
@@ -0,0 +1,121 @@
import type { KeyValues } from '../types/query-types.js';
import type { MessageStore } from '../types//message-store.js';
import type { Signer } from '../types/signer.js';
import type { RecordsDeleteDescriptor, RecordsDeleteMessage, RecordsWriteMessage } from '../types/records-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Message } from '../core/message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type RecordsDeleteOptions = {
recordId: string;
messageTimestamp?: string;
protocolRole?: string;
signer: Signer;
/**
* Denotes if all the descendent records should be purged. Defaults to `false`.
*/
prune?: boolean
/**
* The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant.
*/
delegatedGrant?: RecordsWriteMessage;
};
export class RecordsDelete extends AbstractMessage<RecordsDeleteMessage> {
public static async parse(message: RecordsDeleteMessage): Promise<RecordsDelete> {
let signaturePayload;
if (message.authorization !== undefined) {
signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
}
await Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload);
Time.validateTimestamp(message.descriptor.messageTimestamp);
const recordsDelete = new RecordsDelete(message);
return recordsDelete;
}
/**
* Creates a RecordsDelete message.
* @param options.recordId If `undefined`, will be auto-filled as a originating message as convenience for developer.
* @param options.messageTimestamp If `undefined`, it will be auto-filled with current time.
*/
public static async create(options: RecordsDeleteOptions): Promise<RecordsDelete> {
const recordId = options.recordId;
const currentTime = Time.getCurrentTimestamp();
const descriptor: RecordsDeleteDescriptor = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Delete,
messageTimestamp : options.messageTimestamp ?? currentTime,
recordId,
prune : options.prune ?? false
};
const authorization = await Message.createAuthorization({
descriptor,
signer : options.signer,
protocolRole : options.protocolRole,
delegatedGrant : options.delegatedGrant
});
const message: RecordsDeleteMessage = { descriptor, authorization };
Message.validateJsonSchema(message);
return new RecordsDelete(message);
}
/**
* Indexed properties needed for MessageStore indexing.
*/
public constructIndexes(
initialWrite: RecordsWriteMessage,
): KeyValues {
const message = this.message;
const descriptor = { ...message.descriptor };
// we add the immutable properties from the initial RecordsWrite message in order to use them when querying relevant deletes.
const { protocol, protocolPath, recipient, schema, parentId, dateCreated } = initialWrite.descriptor;
// NOTE: the "trick" not may not be apparent on how a query is able to omit deleted records:
// we intentionally not add index for `isLatestBaseState` at all, this means that upon a successful delete,
// no messages with the record ID will match any query because queries by design filter by `isLatestBaseState = true`,
// `isLatestBaseState` for the initial delete would have been toggled to `false`
const indexes: { [key:string]: string | boolean | undefined } = {
// isLatestBaseState : "true", // intentionally showing that this index is omitted
protocol, protocolPath, recipient, schema, parentId, dateCreated,
contextId : initialWrite.contextId,
author : this.author!,
...descriptor
};
removeUndefinedProperties(indexes);
return indexes as KeyValues;
}
/*
* Authorizes the delegate who signed the message.
* @param messageStore Used to check if the grant has been revoked.
*/
public async authorizeDelegate(recordsWriteToDelete: RecordsWriteMessage, messageStore: MessageStore): Promise<void> {
const delegatedGrant = await PermissionGrant.parse(this.message.authorization!.authorDelegatedGrant!);
await RecordsGrantAuthorization.authorizeDelete({
recordsDeleteMessage : this.message,
recordsWriteToDelete,
expectedGrantor : this.author!,
expectedGrantee : this.signer!,
permissionGrant : delegatedGrant,
messageStore
});
}
}
@@ -0,0 +1,131 @@
import type { MessageStore } from '../types//message-store.js';
import type { Pagination } from '../types/message-types.js';
import type { Signer } from '../types/signer.js';
import type { RecordsFilter, RecordsQueryDescriptor, RecordsQueryMessage, RecordsWriteMessage } from '../types/records-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { DateSort } from '../types/records-types.js';
import { Message } from '../core/message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
export type RecordsQueryOptions = {
messageTimestamp?: string;
filter: RecordsFilter;
dateSort?: DateSort;
pagination?: Pagination;
signer?: Signer;
protocolRole?: string;
/**
* The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant.
*/
delegatedGrant?: RecordsWriteMessage;
};
/**
* A class representing a RecordsQuery DWN message.
*/
export class RecordsQuery extends AbstractMessage<RecordsQueryMessage> {
public static async parse(message: RecordsQueryMessage): Promise<RecordsQuery> {
if (message.descriptor.filter.published === false) {
if (message.descriptor.dateSort === DateSort.PublishedAscending || message.descriptor.dateSort === DateSort.PublishedDescending) {
throw new DwnError(
DwnErrorCode.RecordsQueryParseFilterPublishedSortInvalid,
`queries must not filter for \`published:false\` and sort by ${message.descriptor.dateSort}`
);
}
}
let signaturePayload;
if (message.authorization !== undefined) {
signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
}
await Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload);
if (signaturePayload?.protocolRole !== undefined) {
if (message.descriptor.filter.protocolPath === undefined) {
throw new DwnError(
DwnErrorCode.RecordsQueryFilterMissingRequiredProperties,
'Role-authorized queries must include `protocolPath` in the filter'
);
}
}
if (message.descriptor.filter.protocol !== undefined) {
validateProtocolUrlNormalized(message.descriptor.filter.protocol);
}
if (message.descriptor.filter.schema !== undefined) {
validateSchemaUrlNormalized(message.descriptor.filter.schema);
}
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new RecordsQuery(message);
}
public static async create(options: RecordsQueryOptions): Promise<RecordsQuery> {
const descriptor: RecordsQueryDescriptor = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Query,
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
filter : Records.normalizeFilter(options.filter),
dateSort : options.dateSort,
pagination : options.pagination,
};
if (options.filter.published === false) {
if (options.dateSort === DateSort.PublishedAscending || options.dateSort === DateSort.PublishedDescending) {
throw new DwnError(
DwnErrorCode.RecordsQueryCreateFilterPublishedSortInvalid,
`queries must not filter for \`published:false\` and sort by ${options.dateSort}`
);
}
}
// delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID:
// Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded
removeUndefinedProperties(descriptor);
// only generate the `authorization` property if signature input is given
const signer = options.signer;
let authorization;
if (signer) {
authorization = await Message.createAuthorization({
descriptor,
signer,
protocolRole : options.protocolRole,
delegatedGrant : options.delegatedGrant
});
}
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
return new RecordsQuery(message);
}
/**
* Authorizes the delegate who signed this message.
* @param messageStore Used to check if the grant has been revoked.
*/
public async authorizeDelegate(messageStore: MessageStore): Promise<void> {
const delegatedGrant = await PermissionGrant.parse(this.message.authorization!.authorDelegatedGrant!);
await RecordsGrantAuthorization.authorizeQueryOrSubscribe({
incomingMessage : this.message,
expectedGrantee : this.signer!,
expectedGrantor : this.author!,
permissionGrant : delegatedGrant,
messageStore
});
}
}
@@ -0,0 +1,100 @@
import type { MessageStore } from '../types//message-store.js';
import type { Signer } from '../types/signer.js';
import type { RecordsFilter , RecordsReadDescriptor, RecordsReadMessage, RecordsWriteMessage } from '../types/records-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Message } from '../core/message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type RecordsReadOptions = {
filter: RecordsFilter;
messageTimestamp?: string;
signer?: Signer;
permissionGrantId?: string;
/**
* Used when authorizing protocol records.
* The protocol path to the role record type whose recipient is the author of this RecordsRead
*/
protocolRole?: string;
/**
* The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant.
*/
delegatedGrant?: RecordsWriteMessage;
};
export class RecordsRead extends AbstractMessage<RecordsReadMessage> {
public static async parse(message: RecordsReadMessage): Promise<RecordsRead> {
let signaturePayload;
if (message.authorization !== undefined) {
signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
}
await Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload);
Time.validateTimestamp(message.descriptor.messageTimestamp);
const recordsRead = new RecordsRead(message);
return recordsRead;
}
/**
* Creates a RecordsRead message.
* @param options.recordId If `undefined`, will be auto-filled as a originating message as convenience for developer.
* @param options.date If `undefined`, it will be auto-filled with current time.
*
* @throws {DwnError} when a combination of required RecordsReadOptions are missing
*/
public static async create(options: RecordsReadOptions): Promise<RecordsRead> {
const { filter, signer, permissionGrantId, protocolRole } = options;
const currentTime = Time.getCurrentTimestamp();
const descriptor: RecordsReadDescriptor = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Read,
filter : Records.normalizeFilter(filter),
messageTimestamp : options.messageTimestamp ?? currentTime,
};
removeUndefinedProperties(descriptor);
// only generate the `authorization` property if signature input is given
let authorization = undefined;
if (signer !== undefined) {
authorization = await Message.createAuthorization({
descriptor,
signer,
permissionGrantId,
protocolRole,
delegatedGrant: options.delegatedGrant
});
}
const message: RecordsReadMessage = { descriptor, authorization };
Message.validateJsonSchema(message);
return new RecordsRead(message);
}
/**
* Authorizes the delegate who signed this message.
* @param messageStore Used to check if the grant has been revoked.
*/
public async authorizeDelegate(matchedRecordsWrite: RecordsWriteMessage, messageStore: MessageStore): Promise<void> {
const delegatedGrant = await PermissionGrant.parse(this.message.authorization!.authorDelegatedGrant!);
await RecordsGrantAuthorization.authorizeRead({
recordsReadMessage : this.message,
recordsWriteMessageToBeRead : matchedRecordsWrite,
expectedGrantor : this.author!,
expectedGrantee : this.signer!,
permissionGrant : delegatedGrant,
messageStore
});
}
}
@@ -0,0 +1,104 @@
import type { MessageStore } from '../types/message-store.js';
import type { Signer } from '../types/signer.js';
import type { RecordsFilter, RecordsSubscribeDescriptor, RecordsSubscribeMessage, RecordsWriteMessage } from '../types/records-types.js';
import { AbstractMessage } from '../core/abstract-message.js';
import { Message } from '../core/message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { Records } from '../utils/records.js';
import { RecordsGrantAuthorization } from '../core/records-grant-authorization.js';
import { removeUndefinedProperties } from '../utils/object.js';
import { Time } from '../utils/time.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { validateProtocolUrlNormalized, validateSchemaUrlNormalized } from '../utils/url.js';
export type RecordsSubscribeOptions = {
messageTimestamp?: string;
filter: RecordsFilter;
signer?: Signer;
protocolRole?: string;
/**
* The delegated grant to sign on behalf of the logical author, which is the grantor (`grantedBy`) of the delegated grant.
*/
delegatedGrant?: RecordsWriteMessage;
};
/**
* A class representing a RecordsSubscribe DWN message.
*/
export class RecordsSubscribe extends AbstractMessage<RecordsSubscribeMessage> {
public static async parse(message: RecordsSubscribeMessage): Promise<RecordsSubscribe> {
let signaturePayload;
if (message.authorization !== undefined) {
signaturePayload = await Message.validateSignatureStructure(message.authorization.signature, message.descriptor);
}
await Records.validateDelegatedGrantReferentialIntegrity(message, signaturePayload);
if (signaturePayload?.protocolRole !== undefined) {
if (message.descriptor.filter.protocolPath === undefined) {
throw new DwnError(
DwnErrorCode.RecordsSubscribeFilterMissingRequiredProperties,
'Role-authorized subscriptions must include `protocolPath` in the filter'
);
}
}
if (message.descriptor.filter.protocol !== undefined) {
validateProtocolUrlNormalized(message.descriptor.filter.protocol);
}
if (message.descriptor.filter.schema !== undefined) {
validateSchemaUrlNormalized(message.descriptor.filter.schema);
}
Time.validateTimestamp(message.descriptor.messageTimestamp);
return new RecordsSubscribe(message);
}
public static async create(options: RecordsSubscribeOptions): Promise<RecordsSubscribe> {
const descriptor: RecordsSubscribeDescriptor = {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Subscribe,
messageTimestamp : options.messageTimestamp ?? Time.getCurrentTimestamp(),
filter : Records.normalizeFilter(options.filter),
};
// delete all descriptor properties that are `undefined` else the code will encounter the following IPLD issue when attempting to generate CID:
// Error: `undefined` is not supported by the IPLD Data Model and cannot be encoded
removeUndefinedProperties(descriptor);
// only generate the `authorization` property if signature input is given
const signer = options.signer;
let authorization;
if (signer) {
authorization = await Message.createAuthorization({
descriptor,
signer,
protocolRole : options.protocolRole,
delegatedGrant : options.delegatedGrant
});
}
const message = { descriptor, authorization };
Message.validateJsonSchema(message);
return new RecordsSubscribe(message);
}
/**
* Authorizes the delegate who signed the message.
* @param messageStore Used to check if the grant has been revoked.
*/
public async authorizeDelegate(messageStore: MessageStore): Promise<void> {
const delegatedGrant = await PermissionGrant.parse(this.message.authorization!.authorDelegatedGrant!);
await RecordsGrantAuthorization.authorizeQueryOrSubscribe({
incomingMessage : this.message,
expectedGrantor : this.author!,
expectedGrantee : this.signer!,
permissionGrant : delegatedGrant,
messageStore
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
import * as Ed25519 from '@noble/ed25519';
import type { PrivateJwk, PublicJwk, SignatureAlgorithm } from '../../../types/jose-types.js';
import { Encoder } from '../../../utils/encoder.js';
import { DwnError, DwnErrorCode } from '../../../core/dwn-error.js';
function validateKey(jwk: PrivateJwk | PublicJwk): void {
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
throw new DwnError(DwnErrorCode.Ed25519InvalidJwk, 'invalid jwk. kty MUST be OKP. crv MUST be Ed25519');
}
}
function publicKeyToJwk(publicKeyBytes: Uint8Array): PublicJwk {
const x = Encoder.bytesToBase64Url(publicKeyBytes);
const publicJwk: PublicJwk = {
alg : 'EdDSA',
kty : 'OKP',
crv : 'Ed25519',
x
};
return publicJwk;
}
export const ed25519: SignatureAlgorithm = {
sign: async (content: Uint8Array, privateJwk: PrivateJwk): Promise<Uint8Array> => {
validateKey(privateJwk);
const privateKeyBytes = Encoder.base64UrlToBytes(privateJwk.d);
return Ed25519.signAsync(content, privateKeyBytes);
},
verify: async (content: Uint8Array, signature: Uint8Array, publicJwk: PublicJwk): Promise<boolean> => {
validateKey(publicJwk);
const publicKeyBytes = Encoder.base64UrlToBytes(publicJwk.x);
return Ed25519.verifyAsync(signature, content, publicKeyBytes);
},
generateKeyPair: async (): Promise<{publicJwk: PublicJwk, privateJwk: PrivateJwk}> => {
const privateKeyBytes = Ed25519.utils.randomPrivateKey();
const publicKeyBytes = await Ed25519.getPublicKeyAsync(privateKeyBytes);
const d = Encoder.bytesToBase64Url(privateKeyBytes);
const publicJwk = publicKeyToJwk(publicKeyBytes);
const privateJwk: PrivateJwk = { ...publicJwk, d };
return { publicJwk, privateJwk };
},
publicKeyToJwk: async (publicKeyBytes: Uint8Array): Promise<PublicJwk> => {
return publicKeyToJwk(publicKeyBytes);
}
};
@@ -0,0 +1,22 @@
import type { SignatureAlgorithm } from '../../../types/jose-types.js';
import { ed25519 } from './ed25519.js';
import { Secp256k1 } from '../../../utils/secp256k1.js';
import { Secp256r1 } from '../../../utils/secp256r1.js';
// the key should be the appropriate `crv` value
export const signatureAlgorithms: Record<string, SignatureAlgorithm> = {
'Ed25519' : ed25519,
'secp256k1' : {
sign : Secp256k1.sign,
verify : Secp256k1.verify,
generateKeyPair : Secp256k1.generateKeyPair,
publicKeyToJwk : Secp256k1.publicKeyToJwk
},
'P-256': {
sign : Secp256r1.sign,
verify : Secp256r1.verify,
generateKeyPair : Secp256r1.generateKeyPair,
publicKeyToJwk : Secp256r1.publicKeyToJwk,
},
};
@@ -0,0 +1,48 @@
import type { GeneralJws } from '../../../types/jws-types.js';
import type { Signer } from '../../../types/signer.js';
import { Encoder } from '../../../utils/encoder.js';
export class GeneralJwsBuilder {
private jws: GeneralJws;
private constructor(jws: GeneralJws) {
this.jws = jws;
}
static async create(payload: Uint8Array, signers: Signer[] = []): Promise<GeneralJwsBuilder> {
const jws: GeneralJws = {
payload : Encoder.bytesToBase64Url(payload),
signatures : []
};
const builder = new GeneralJwsBuilder(jws);
for (const signer of signers) {
await builder.addSignature(signer);
}
return builder;
}
async addSignature(signer: Signer): Promise<void> {
const protectedHeader = {
kid : signer.keyId,
alg : signer.algorithm
};
const protectedHeaderString = JSON.stringify(protectedHeader);
const protectedHeaderBase64UrlString = Encoder.stringToBase64Url(protectedHeaderString);
const signingInputString = `${protectedHeaderBase64UrlString}.${this.jws.payload}`;
const signingInputBytes = Encoder.stringToBytes(signingInputString);
const signatureBytes = await signer.sign(signingInputBytes);
const signature = Encoder.bytesToBase64Url(signatureBytes);
this.jws.signatures.push({ protected: protectedHeaderBase64UrlString, signature });
}
getJws(): GeneralJws {
return this.jws;
}
}
@@ -0,0 +1,112 @@
import type { Cache } from '../../../types/cache.js';
import type { GeneralJws } from '../../../types/jws-types.js';
import type { PublicJwk } from '../../../types/jose-types.js';
import type { DidResolver, DidVerificationMethod } from '@web5/dids';
import { Jws } from '../../../utils/jws.js';
import { MemoryCache } from '../../../utils/memory-cache.js';
import { validateJsonSchema } from '../../../schema-validator.js';
import { DwnError, DwnErrorCode } from '../../../core/dwn-error.js';
type VerificationResult = {
/** DIDs of all signers */
signers: string[];
};
/**
* Verifies the signature(s) of a General JWS.
*/
export class GeneralJwsVerifier {
private static _singleton: GeneralJwsVerifier;
cache: Cache;
private constructor(cache?: Cache) {
this.cache = cache || new MemoryCache(600);
}
private static get singleton(): GeneralJwsVerifier {
if (GeneralJwsVerifier._singleton === undefined) {
GeneralJwsVerifier._singleton = new GeneralJwsVerifier();
}
return GeneralJwsVerifier._singleton;
}
/**
* Verifies the signatures of the given General JWS.
* @returns the list of signers that have valid signatures.
*/
public static async verifySignatures(jws: GeneralJws, didResolver: DidResolver): Promise<VerificationResult> {
return await GeneralJwsVerifier.singleton.verifySignatures(jws, didResolver);
}
/**
* Verifies the signatures of the given General JWS.
* @returns the list of signers that have valid signatures.
*/
public async verifySignatures(jws: GeneralJws, didResolver: DidResolver): Promise<VerificationResult> {
const signers: string[] = [];
for (const signatureEntry of jws.signatures) {
let isVerified: boolean;
const kid = Jws.getKid(signatureEntry);
const cacheKey = `${signatureEntry.protected}.${jws.payload}.${signatureEntry.signature}`;
const cachedValue = await this.cache.get(cacheKey);
// explicit `undefined` check to differentiate `false`
if (cachedValue === undefined) {
const publicJwk = await GeneralJwsVerifier.getPublicKey(kid, didResolver);
isVerified = await Jws.verifySignature(jws.payload, signatureEntry, publicJwk);
await this.cache.set(cacheKey, isVerified);
} else {
isVerified = cachedValue;
}
const did = Jws.extractDid(kid);
if (isVerified) {
signers.push(did);
} else {
throw new DwnError(DwnErrorCode.GeneralJwsVerifierInvalidSignature, `Signature verification failed for ${did}`);
}
}
return { signers };
}
/**
* Gets the public key given a fully qualified key ID (`kid`) by resolving the DID to its DID Document.
*/
private static async getPublicKey(kid: string, didResolver: DidResolver): Promise<PublicJwk> {
// `resolve` throws exception if DID is invalid, DID method is not supported,
// or resolving DID fails
const did = Jws.extractDid(kid);
const { didDocument } = await didResolver.resolve(did);
const { verificationMethod: verificationMethods = [] } = didDocument || {};
let verificationMethod: DidVerificationMethod | undefined;
for (const method of verificationMethods) {
// consider optimizing using a set for O(1) lookups if needed
// key ID in DID Document may or may not be fully qualified. e.g.
// `did:ion:alice#key1` or `#key1`
if (kid.endsWith(method.id)) {
verificationMethod = method;
break;
}
}
if (!verificationMethod) {
throw new DwnError(DwnErrorCode.GeneralJwsVerifierGetPublicKeyNotFound, 'public key needed to verify signature not found in DID Document');
}
validateJsonSchema('JwkVerificationMethod', verificationMethod);
const { publicKeyJwk: publicJwk } = verificationMethod;
return publicJwk as PublicJwk;
}
}
@@ -0,0 +1,90 @@
import type { RecordsQueryReplyEntry, RecordsWriteMessage } from '../types/records-types.js';
import type { PermissionConditions, PermissionGrantData, PermissionScope } from '../types/permission-types.js';
import { Encoder } from '../utils/encoder.js';
import { Message } from '../core/message.js';
/**
* A class representing a Permission Grant for a more convenient abstraction.
*/
export class PermissionGrant {
/**
* The ID of the permission grant, which is the record ID DWN message.
*/
public readonly id: string;
/**
* The grantor of the permission.
*/
public readonly grantor: string;
/**
* The grantee of the permission.
*/
public readonly grantee: string;
/**
* The date at which the grant was given.
*/
public readonly dateGranted: string;
/**
* Optional string that communicates what the grant would be used for
*/
public readonly description?: string;
/**
* Optional CID of a permission request. This is optional because grants may be given without being officially requested
*/
public readonly requestId?: string;
/**
* Timestamp at which this grant will no longer be active.
*/
public readonly dateExpires: string;
/**
* Whether this grant is delegated or not. If `true`, the `grantedTo` will be able to act as the `grantedTo` within the scope of this grant.
*/
public readonly delegated?: boolean;
/**
* The scope of the allowed access.
*/
public readonly scope: PermissionScope;
/**
* Optional conditions that must be met when the grant is used.
*/
public readonly conditions?: PermissionConditions;
public static async parse(message: RecordsWriteMessage): Promise<PermissionGrant> {
const permissionGrant = new PermissionGrant(message);
return permissionGrant;
}
/**
* Creates a Permission Grant abstraction for
*/
private constructor(message: RecordsWriteMessage) {
// properties derived from the generic DWN message properties
this.id = message.recordId;
this.grantor = Message.getSigner(message)!;
this.grantee = message.descriptor.recipient!;
this.dateGranted = message.descriptor.dateCreated;
// properties from the data payload itself.
const permissionGrantEncoded = (message as RecordsQueryReplyEntry).encodedData!;
const permissionGrant = Encoder.base64UrlToObject(permissionGrantEncoded) as PermissionGrantData;
this.dateExpires = permissionGrant.dateExpires;
this.delegated = permissionGrant.delegated;
this.description = permissionGrant.description;
this.requestId = permissionGrant.requestId;
this.scope = permissionGrant.scope;
this.conditions = permissionGrant.conditions;
}
}
@@ -0,0 +1,388 @@
import type { GenericMessage } from '../types/message-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { ProtocolDefinition } from '../types/protocols-types.js';
import type { Signer } from '../types/signer.js';
import type { DataEncodedRecordsWriteMessage, RecordsWriteMessage } from '../types/records-types.js';
import type { PermissionConditions, PermissionGrantData, PermissionRequestData, PermissionRevocationData, PermissionScope, RecordsPermissionScope } from '../types/permission-types.js';
import { Encoder } from '../utils/encoder.js';
import { PermissionGrant } from './permission-grant.js';
import { RecordsWrite } from '../../src/interfaces/records-write.js';
import { Time } from '../utils/time.js';
import { validateJsonSchema } from '../schema-validator.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { normalizeProtocolUrl, normalizeSchemaUrl } from '../utils/url.js';
/**
* Options for creating a permission request.
*/
export type PermissionRequestCreateOptions = {
/**
* The signer of the request.
*/
signer?: Signer;
dateRequested?: string;
// remaining properties are contained within the data payload of the record
description?: string;
delegated: boolean;
scope: PermissionScope;
conditions?: PermissionConditions;
};
/**
* Options for creating a permission grant.
*/
export type PermissionGrantCreateOptions = {
/**
* The signer of the grant.
*/
signer?: Signer;
grantedTo: string;
dateGranted?: string;
// remaining properties are contained within the data payload of the record
/**
* Expire time in UTC ISO-8601 format with microsecond precision.
*/
dateExpires: string;
requestId?: string;
description?: string;
delegated?: boolean;
scope: PermissionScope;
conditions?: PermissionConditions;
};
/**
* Options for creating a permission revocation.
*/
export type PermissionRevocationCreateOptions = {
/**
* The signer of the grant.
*/
signer?: Signer;
grantId: string;
dateRevoked?: string;
// remaining properties are contained within the data payload of the record
description?: string;
};
/**
* This is a first-class DWN protocol for managing permission grants of a given DWN.
*/
export class PermissionsProtocol {
/**
* The URI of the DWN Permissions protocol.
*/
public static readonly uri = 'https://tbd.website/dwn/permissions';
/**
* The protocol path of the `request` record.
*/
public static readonly requestPath = 'request';
/**
* The protocol path of the `grant` record.
*/
public static readonly grantPath = 'grant';
/**
* The protocol path of the `revocation` record.
*/
public static readonly revocationPath = 'grant/revocation';
/**
* The definition of the Permissions protocol.
*/
public static readonly definition: ProtocolDefinition = {
published : true,
protocol : PermissionsProtocol.uri,
types : {
request: {
dataFormats: ['application/json']
},
grant: {
dataFormats: ['application/json']
},
revocation: {
dataFormats: ['application/json']
}
},
structure: {
request: {
$size: {
max: 10000
},
$actions: [
{
who : 'anyone',
can : ['create']
}
]
},
grant: {
$size: {
max: 10000
},
$actions: [
{
who : 'recipient',
of : 'grant',
can : ['read', 'query']
}
],
revocation: {
$size: {
max: 10000
},
$actions: [
{
who : 'anyone',
can : ['read']
}
]
}
}
}
};
public static parseRequest(base64UrlEncodedRequest: string): PermissionRequestData {
return Encoder.base64UrlToObject(base64UrlEncodedRequest);
}
/**
* Convenience method to create a permission request.
*/
public static async createRequest(options: PermissionRequestCreateOptions): Promise<{
recordsWrite: RecordsWrite,
permissionRequestData: PermissionRequestData,
permissionRequestBytes: Uint8Array
}> {
const scope = PermissionsProtocol.normalizePermissionScope(options.scope);
const permissionRequestData: PermissionRequestData = {
description : options.description,
delegated : options.delegated,
scope,
conditions : options.conditions,
};
const permissionRequestBytes = Encoder.objectToBytes(permissionRequestData);
const recordsWrite = await RecordsWrite.create({
signer : options.signer,
messageTimestamp : options.dateRequested,
protocol : PermissionsProtocol.uri,
protocolPath : PermissionsProtocol.requestPath,
dataFormat : 'application/json',
data : permissionRequestBytes,
});
return {
recordsWrite,
permissionRequestData,
permissionRequestBytes
};
}
/**
* Convenience method to create a permission grant.
*/
public static async createGrant(options: PermissionGrantCreateOptions): Promise<{
recordsWrite: RecordsWrite,
permissionGrantData: PermissionGrantData,
permissionGrantBytes: Uint8Array,
dataEncodedMessage: DataEncodedRecordsWriteMessage,
}> {
const scope = PermissionsProtocol.normalizePermissionScope(options.scope);
const permissionGrantData: PermissionGrantData = {
dateExpires : options.dateExpires,
requestId : options.requestId,
description : options.description,
delegated : options.delegated,
scope,
conditions : options.conditions,
};
const permissionGrantBytes = Encoder.objectToBytes(permissionGrantData);
const recordsWrite = await RecordsWrite.create({
signer : options.signer,
messageTimestamp : options.dateGranted,
dateCreated : options.dateGranted,
recipient : options.grantedTo,
protocol : PermissionsProtocol.uri,
protocolPath : PermissionsProtocol.grantPath,
dataFormat : 'application/json',
data : permissionGrantBytes,
});
const dataEncodedMessage: DataEncodedRecordsWriteMessage = {
...recordsWrite.message,
encodedData: Encoder.bytesToBase64Url(permissionGrantBytes)
};
return {
recordsWrite,
permissionGrantData,
permissionGrantBytes,
dataEncodedMessage
};
}
/**
* Convenience method to create a permission revocation.
*/
public static async createRevocation(options: PermissionRevocationCreateOptions): Promise<{
recordsWrite: RecordsWrite,
permissionRevocationData: PermissionRevocationData,
permissionRevocationBytes: Uint8Array
}> {
const permissionRevocationData: PermissionRevocationData = {
description: options.description,
};
const permissionRevocationBytes = Encoder.objectToBytes(permissionRevocationData);
const recordsWrite = await RecordsWrite.create({
signer : options.signer,
parentContextId : options.grantId, // NOTE: since the grant is the root record, its record ID is also the context ID
protocol : PermissionsProtocol.uri,
protocolPath : PermissionsProtocol.revocationPath,
dataFormat : 'application/json',
data : permissionRevocationBytes,
});
return {
recordsWrite,
permissionRevocationData,
permissionRevocationBytes
};
}
/**
* Validates the given Permissions protocol RecordsWrite. It can be a request, grant, or revocation.
*/
public static validateSchema(recordsWriteMessage: RecordsWriteMessage, dataBytes: Uint8Array): void {
const dataString = Encoder.bytesToString(dataBytes);
const dataObject = JSON.parse(dataString);
if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.requestPath) {
validateJsonSchema('PermissionRequestData', dataObject);
} else if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.grantPath) {
validateJsonSchema('PermissionGrantData', dataObject);
// more nuanced validation that are annoying/difficult to do using JSON schema
const permissionGrantData = dataObject as PermissionGrantData;
PermissionsProtocol.validateScope(permissionGrantData.scope);
Time.validateTimestamp(permissionGrantData.dateExpires);
} else if (recordsWriteMessage.descriptor.protocolPath === PermissionsProtocol.revocationPath) {
validateJsonSchema('PermissionRevocationData', dataObject);
} else {
// defensive programming, should be unreachable externally
throw new DwnError(
DwnErrorCode.PermissionsProtocolValidateSchemaUnexpectedRecord,
`Unexpected permission record: ${recordsWriteMessage.descriptor.protocolPath}`
);
}
}
/**
* Fetches PermissionGrant with the specified `recordID`.
* @returns the PermissionGrant matching the `recordId` specified.
* @throws {Error} if PermissionGrant does not exist
*/
public static async fetchGrant(
tenant: string,
messageStore: MessageStore,
permissionGrantId: string,
): Promise<PermissionGrant> {
const grantQuery = {
recordId : permissionGrantId,
isLatestBaseState : true
};
const { messages } = await messageStore.query(tenant, [grantQuery]);
const possibleGrantMessage: GenericMessage | undefined = messages[0];
const dwnInterface = possibleGrantMessage?.descriptor.interface;
const dwnMethod = possibleGrantMessage?.descriptor.method;
if (dwnInterface !== DwnInterfaceName.Records ||
dwnMethod !== DwnMethodName.Write ||
(possibleGrantMessage as RecordsWriteMessage).descriptor.protocolPath !== PermissionsProtocol.grantPath) {
throw new DwnError(
DwnErrorCode.GrantAuthorizationGrantMissing,
`Could not find permission grant with record ID ${permissionGrantId}.`
);
}
const permissionGrantMessage = possibleGrantMessage as RecordsWriteMessage;
const permissionGrant = await PermissionGrant.parse(permissionGrantMessage);
return permissionGrant;
}
/**
* Normalizes the given permission scope if needed.
* @returns The normalized permission scope.
*/
private static normalizePermissionScope(permissionScope: PermissionScope): PermissionScope {
const scope = { ...permissionScope };
if (PermissionsProtocol.isRecordPermissionScope(scope)) {
// normalize protocol and schema URLs if they are present
if (scope.protocol !== undefined) {
scope.protocol = normalizeProtocolUrl(scope.protocol);
}
if (scope.schema !== undefined) {
scope.schema = normalizeSchemaUrl(scope.schema);
}
}
return scope;
}
/**
* Type guard to determine if the scope is a record permission scope.
*/
private static isRecordPermissionScope(scope: PermissionScope): scope is RecordsPermissionScope {
return scope.interface === 'Records';
}
/**
* Validates scope.
*/
private static validateScope(scope: PermissionScope): void {
if (!this.isRecordPermissionScope(scope)) {
return;
}
// else we are dealing with a RecordsPermissionScope
// `schema` scopes may not have protocol-related fields
if (scope.schema !== undefined) {
if (scope.protocol !== undefined || scope.contextId !== undefined || scope.protocolPath) {
throw new DwnError(
DwnErrorCode.PermissionsProtocolValidateScopeSchemaProhibitedProperties,
'Permission grants that have `schema` present cannot also have protocol-related properties present'
);
}
}
if (scope.protocol !== undefined) {
// `contextId` and `protocolPath` are mutually exclusive
if (scope.contextId !== undefined && scope.protocolPath !== undefined) {
throw new DwnError(
DwnErrorCode.PermissionsProtocolValidateScopeContextIdProhibitedProperties,
'Permission grants cannot have both `contextId` and `protocolPath` present'
);
}
}
}
};
@@ -0,0 +1,46 @@
import * as precompiledValidators from '../generated/precompiled-validators.js';
import { DwnError, DwnErrorCode } from './core/dwn-error.js';
/**
* Validates the given payload using JSON schema keyed by the given schema name. Throws if the given payload fails validation.
* @param schemaName the schema name use to look up the JSON schema to be used for schema validation.
* The list of schema names can be found in compile-validators.js
* @param payload javascript object to be validated
*/
export function validateJsonSchema(schemaName: string, payload: any): void {
// const validateFn = validator.getSchema(schemaName);
const validateFn = (precompiledValidators as any)[schemaName];
if (!validateFn) {
throw new DwnError(DwnErrorCode.SchemaValidatorSchemaNotFound, `schema for ${schemaName} not found.`);
}
validateFn(payload);
if (!validateFn.errors) {
return;
}
// AJV is configured by default to stop validating after the 1st error is encountered which means
// there will only ever be one error;
const [ errorObj ] = validateFn.errors;
let { instancePath, message, keyword } = errorObj;
if (!instancePath) {
instancePath = schemaName;
}
// handle a few frequently occurred errors to give more meaningful error for debugging
if (keyword === 'additionalProperties') {
const keyword = errorObj.params.additionalProperty;
throw new DwnError(DwnErrorCode.SchemaValidatorAdditionalPropertyNotAllowed, `${message}: ${instancePath}: ${keyword}`);
}
if (keyword === 'unevaluatedProperties') {
const keyword = errorObj.params.unevaluatedProperty;
throw new DwnError(DwnErrorCode.SchemaValidatorUnevaluatedPropertyNotAllowed, `${message}: ${instancePath}: ${keyword}`);
}
throw new DwnError(DwnErrorCode.SchemaValidatorFailure, `${instancePath}: ${message}`);
}
@@ -0,0 +1,113 @@
import { CID } from 'multiformats';
import type { AbortOptions, AwaitIterable } from 'interface-store';
import type { Blockstore, Pair } from 'interface-blockstore';
import { createLevelDatabase, LevelWrapper } from './level-wrapper.js';
// `level` works in Node.js 12+ and Electron 5+ on Linux, Mac OS, Windows and
// FreeBSD, including any future Node.js and Electron release thanks to Node-API, including ARM
// platforms like Raspberry Pi and Android, as well as in Chrome, Firefox, Edge, Safari, iOS Safari
// and Chrome for Android.
/**
* Blockstore implementation using LevelDB for storing the actual messages (in the case of MessageStore)
* or the data associated with messages (in the case of a DataStore).
*/
export class BlockstoreLevel implements Blockstore {
config: BlockstoreLevelConfig;
db: LevelWrapper<Uint8Array>;
constructor(config: BlockstoreLevelConfig, db?: LevelWrapper<Uint8Array>) {
this.config = {
createLevelDatabase,
...config
};
this.db = db ?? new LevelWrapper<Uint8Array>({ ...this.config, valueEncoding: 'binary' });
}
async open(): Promise<void> {
return this.db.open();
}
async close(): Promise<void> {
return this.db.close();
}
async partition(name: string): Promise<BlockstoreLevel> {
const db = await this.db.partition(name);
return new BlockstoreLevel({ ...this.config, location: '' }, db);
}
async put(key: CID | string, val: Uint8Array, options?: AbortOptions): Promise<CID> {
await this.db.put(String(key), val, options);
return CID.parse(key.toString());
}
async get(key: CID | string, options?: AbortOptions): Promise<Uint8Array> {
const result = await this.db.get(String(key), options);
return result!;
}
async has(key: CID | string, options?: AbortOptions): Promise<boolean> {
return this.db.has(String(key), options);
}
async delete(key: CID | string, options?: AbortOptions): Promise<void> {
return this.db.delete(String(key), options);
}
async isEmpty(options?: AbortOptions): Promise<boolean> {
return this.db.isEmpty(options);
}
async * putMany(source: AwaitIterable<Pair>, options?: AbortOptions): AsyncIterable<CID> {
for await (const entry of source) {
await this.put(entry.cid, entry.block, options);
yield entry.cid;
}
}
async * getMany(source: AwaitIterable<CID>, options?: AbortOptions): AsyncIterable<Pair> {
for await (const key of source) {
yield {
cid : key,
block : await this.get(key, options)
};
}
}
async * getAll(options?: AbortOptions): AsyncIterable<Pair> {
// @ts-expect-error keyEncoding is 'buffer' but types for db.iterator always return the key type as 'string'
const li: AsyncGenerator<[Uint8Array, Uint8Array]> = this.db.iterator({
keys : true,
keyEncoding : 'buffer'
}, options);
for await (const [key, value] of li) {
yield { cid: CID.decode(key), block: value };
}
}
async * deleteMany(source: AwaitIterable<CID>, options?: AbortOptions): AsyncIterable<CID> {
for await (const key of source) {
await this.delete(key, options);
yield key;
}
}
/**
* deletes all entries
*/
async clear(): Promise<void> {
return this.db.clear();
}
}
type BlockstoreLevelConfig = {
location: string,
createLevelDatabase?: typeof createLevelDatabase,
};
@@ -0,0 +1,80 @@
import { CID } from 'multiformats';
import type { AbortOptions, AwaitIterable } from 'interface-store';
import type { Blockstore, Pair } from 'interface-blockstore';
/**
* Mock implementation for the Blockstore interface.
*
* WARNING!!! Purely to be used with `ipfs-unixfs-importer` to compute CID without needing consume any memory.
* This is particularly useful when dealing with large files and a necessity in a large-scale production service environment.
*/
export class BlockstoreMock implements Blockstore {
async open(): Promise<void> {
}
async close(): Promise<void> {
}
async put(key: CID, _val: Uint8Array, _options?: AbortOptions): Promise<CID> {
return key;
}
async get(_key: CID, _options?: AbortOptions): Promise<Uint8Array> {
return new Uint8Array();
}
async has(_key: CID, _options?: AbortOptions): Promise<boolean> {
return false;
}
async delete(_key: CID, _options?: AbortOptions): Promise<void> {
}
async isEmpty(_options?: AbortOptions): Promise<boolean> {
return true;
}
async * putMany(source: AwaitIterable<Pair>, options?: AbortOptions): AsyncIterable<CID> {
for await (const entry of source) {
await this.put(entry.cid, entry.block, options);
yield entry.cid;
}
}
async * getMany(source: AwaitIterable<CID>, options?: AbortOptions): AsyncIterable<Pair> {
for await (const key of source) {
yield {
cid : key,
block : await this.get(key, options)
};
}
}
async * getAll(options?: AbortOptions): AsyncIterable<Pair> {
// @ts-expect-error keyEncoding is 'buffer' but types for db.iterator always return the key type as 'string'
const li: AsyncGenerator<[Uint8Array, Uint8Array]> = this.db.iterator({
keys : true,
keyEncoding : 'buffer'
}, options);
for await (const [key, value] of li) {
yield { cid: CID.decode(key), block: value };
}
}
async * deleteMany(source: AwaitIterable<CID>, options?: AbortOptions): AsyncIterable<CID> {
for await (const key of source) {
await this.delete(key, options);
yield key;
}
}
/**
* deletes all entries
*/
async clear(): Promise<void> {
}
}
@@ -0,0 +1,120 @@
import type { ImportResult } from 'ipfs-unixfs-importer';
import type { DataStore, DataStoreGetResult, DataStorePutResult } from '../types/data-store.js';
import { BlockstoreLevel } from './blockstore-level.js';
import { createLevelDatabase } from './level-wrapper.js';
import { exporter } from 'ipfs-unixfs-exporter';
import { importer } from 'ipfs-unixfs-importer';
import { Readable } from 'readable-stream';
/**
* A simple implementation of {@link DataStore} that works in both the browser and server-side.
* Leverages LevelDB under the hood.
*
* It has the following structure (`+` represents an additional sublevel/partition):
* 'data' + <tenant> + <recordId> + <dataCid> -> <data>
*/
export class DataStoreLevel implements DataStore {
config: DataStoreLevelConfig;
blockstore: BlockstoreLevel;
constructor(config: DataStoreLevelConfig = {}) {
this.config = {
blockstoreLocation: 'DATASTORE',
createLevelDatabase,
...config
};
this.blockstore = new BlockstoreLevel({
location : this.config.blockstoreLocation!,
createLevelDatabase : this.config.createLevelDatabase,
});
}
public async open(): Promise<void> {
await this.blockstore.open();
}
async close(): Promise<void> {
await this.blockstore.close();
}
async put(tenant: string, recordId: string, dataCid: string, dataStream: Readable): Promise<DataStorePutResult> {
const blockstoreForData = await this.getBlockstoreForStoringData(tenant, recordId, dataCid);
const asyncDataBlocks = importer([{ content: dataStream }], blockstoreForData, { cidVersion: 1 });
// NOTE: the last block contains the root CID as well as info to derive the data size
let dataDagRoot!: ImportResult;
for await (dataDagRoot of asyncDataBlocks) { ; }
return {
dataSize: Number(dataDagRoot.unixfs?.fileSize() ?? dataDagRoot.size)
};
}
public async get(tenant: string, recordId: string, dataCid: string): Promise<DataStoreGetResult | undefined> {
const blockstoreForData = await this.getBlockstoreForStoringData(tenant, recordId, dataCid);
const exists = await blockstoreForData.has(dataCid);
if (!exists) {
return undefined;
}
// data is chunked into dag-pb unixfs blocks. re-inflate the chunks.
const dataDagRoot = await exporter(dataCid, blockstoreForData);
const contentIterator = dataDagRoot.content();
const dataStream = new Readable({
async read(): Promise<void> {
const result = await contentIterator.next();
if (result.done) {
this.push(null); // end the stream
} else {
this.push(result.value);
}
}
});
let dataSize = dataDagRoot.size;
if (dataDagRoot.type === 'file' || dataDagRoot.type === 'directory') {
dataSize = dataDagRoot.unixfs.fileSize();
}
return {
dataSize: Number(dataSize),
dataStream,
};
}
public async delete(tenant: string, recordId: string, dataCid: string): Promise<void> {
const blockstoreForData = await this.getBlockstoreForStoringData(tenant, recordId, dataCid);
await blockstoreForData.clear();
}
/**
* Deletes everything in the store. Mainly used in tests.
*/
public async clear(): Promise<void> {
await this.blockstore.clear();
}
/**
* Gets the blockstore used for storing data for the given `tenant -> `recordId` -> `dataCid`.
*/
private async getBlockstoreForStoringData(tenant: string, recordId: string, dataCid: string): Promise<BlockstoreLevel> {
const dataPartitionName = 'data';
const blockstoreForData = await this.blockstore.partition(dataPartitionName);
const blockstoreOfGivenTenant = await blockstoreForData.partition(tenant);
const blockstoreOfGivenRecordId = await blockstoreOfGivenTenant.partition(recordId);
const blockstoreOfGivenDataCidOfRecordId = await blockstoreOfGivenRecordId.partition(dataCid);
return blockstoreOfGivenDataCidOfRecordId;
}
}
type DataStoreLevelConfig = {
blockstoreLocation?: string,
createLevelDatabase?: typeof createLevelDatabase,
};
@@ -0,0 +1,691 @@
import type { EqualFilter, Filter, KeyValues, PaginationCursor, QueryOptions, RangeFilter } from '../types/query-types.js';
import type { LevelWrapperBatchOperation, LevelWrapperIteratorOptions, } from './level-wrapper.js';
import { isEmptyObject } from '../utils/object.js';
import { lexicographicalCompare } from '../utils/string.js';
import { SortDirection } from '../types/query-types.js';
import { createLevelDatabase, LevelWrapper } from './level-wrapper.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { FilterSelector, FilterUtility } from '../utils/filter.js';
type IndexLevelConfig = {
location?: string,
createLevelDatabase?: typeof createLevelDatabase
};
export type IndexedItem = { messageCid: string, indexes: KeyValues };
const INDEX_SUBLEVEL_NAME = 'index';
export interface IndexLevelOptions {
signal?: AbortSignal;
}
/**
* A LevelDB implementation for indexing the messages and events stored in the DWN.
*/
export class IndexLevel {
db: LevelWrapper<string>;
config: IndexLevelConfig;
constructor(config: IndexLevelConfig) {
this.config = {
createLevelDatabase,
...config,
};
this.db = new LevelWrapper<string>({
location : this.config.location!,
createLevelDatabase : this.config.createLevelDatabase,
keyEncoding : 'utf8'
});
}
async open(): Promise<void> {
await this.db.open();
}
async close(): Promise<void> {
await this.db.close();
}
/**
* deletes everything in the underlying index db.
*/
async clear(): Promise<void> {
await this.db.clear();
}
/**
* Put an item into the index using information that will allow it to be queried for.
*
* @param tenant
* @param messageCid a unique ID that represents the item being indexed, this is also used as the cursor value in a query.
* @param indexes - (key-value pairs) to be included as part of indexing this item. Must include at least one indexing property.
* @param options IndexLevelOptions that include an AbortSignal.
*/
async put(
tenant: string,
messageCid: string,
indexes: KeyValues,
options?: IndexLevelOptions
): Promise<void> {
// ensure we have something valid to index
if (isEmptyObject(indexes)) {
throw new DwnError(DwnErrorCode.IndexMissingIndexableProperty, 'Index must include at least one valid indexable property');
}
const item: IndexedItem = { messageCid, indexes };
const opCreationPromises: Promise<LevelWrapperBatchOperation<string>>[] = [];
// create an index entry for each property index
// these indexes are all sortable lexicographically.
for (const indexName in indexes) {
const indexValue = indexes[indexName];
if (Array.isArray(indexValue)) {
for (const indexValueItem of indexValue) {
const partitionOperationPromise = this.createPutIndexedItemOperation(tenant, item, indexName, indexValueItem);
opCreationPromises.push(partitionOperationPromise);
}
} else {
const partitionOperationPromise = this.createPutIndexedItemOperation(tenant, item, indexName, indexValue);
opCreationPromises.push(partitionOperationPromise);
}
}
// create a reverse lookup for the sortedIndex values. This is used during deletion and cursor starting point lookup.
const partitionOperationPromise = this.createOperationForIndexesLookupPartition(
tenant,
{ type: 'put', key: messageCid, value: JSON.stringify(indexes) }
);
opCreationPromises.push(partitionOperationPromise);
const indexOps = await Promise.all(opCreationPromises);
const tenantPartition = await this.db.partition(tenant);
await tenantPartition.batch(indexOps, options);
}
/**
* Deletes all of the index data associated with the item.
*/
async delete(tenant: string, messageCid: string, options?: IndexLevelOptions): Promise<void> {
const opCreationPromises: Promise<LevelWrapperBatchOperation<string>>[] = [];
const indexes = await this.getIndexes(tenant, messageCid);
if (indexes === undefined) {
// invalid messageCid
return;
}
// delete the reverse lookup
const partitionOperationPromise = this.createOperationForIndexesLookupPartition(tenant, { type: 'del', key: messageCid });
opCreationPromises.push(partitionOperationPromise);
// delete the keys for each index
for (const indexName in indexes) {
const indexValue = indexes[indexName];
if (Array.isArray(indexValue)) {
for (const indexValueItem of indexValue) {
const partitionOperationPromise = this.createDeleteIndexedItemOperation(tenant, messageCid, indexName, indexValueItem);
opCreationPromises.push(partitionOperationPromise);
}
} else {
const partitionOperationPromise = this.createDeleteIndexedItemOperation(tenant, messageCid, indexName, indexValue);
opCreationPromises.push(partitionOperationPromise);
}
}
const indexOps = await Promise.all(opCreationPromises);
const tenantPartition = await this.db.partition(tenant);
await tenantPartition.batch(indexOps, options);
}
/**
* Creates an IndexLevel `put` operation for indexing an item, creating a partition by `tenant` and by `indexName`
*/
private async createPutIndexedItemOperation(
tenant: string,
item: IndexedItem,
indexName: string,
indexValue: string | number | boolean
): Promise<LevelWrapperBatchOperation<string>> {
const { messageCid } = item;
// The key is the indexValue followed by the messageCid as a tie-breaker.
// for example if the property is messageTimestamp the key would look like:
// '"2023-05-25T18:23:29.425008Z"\u0000bafyreigs3em7lrclhntzhgvkrf75j2muk6e7ypq3lrw3ffgcpyazyw6pry'
const key = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(indexValue), messageCid);
return this.createOperationForIndexPartition(
tenant,
indexName,
{ type: 'put', key, value: JSON.stringify(item) }
);
}
/**
* Creates an IndexLevel `del` operation for deleting an item, creating a partition by `tenant` and by `indexName`
*/
private async createDeleteIndexedItemOperation(
tenant: string,
messageCid: string,
indexName: string,
indexValue: string | number | boolean
): Promise<LevelWrapperBatchOperation<string>> {
// The key is the indexValue followed by the messageCid as a tie-breaker.
// for example if the property is messageTimestamp the key would look like:
// '"2023-05-25T18:23:29.425008Z"\u0000bafyreigs3em7lrclhntzhgvkrf75j2muk6e7ypq3lrw3ffgcpyazyw6pry'
const key = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(indexValue), messageCid);
return this.createOperationForIndexPartition(
tenant,
indexName,
{ type: 'del', key }
);
}
/**
* Wraps the given operation as an operation for the specified index partition.
*/
private async createOperationForIndexPartition(tenant: string, indexName: string, operation: LevelWrapperBatchOperation<string>)
: Promise<LevelWrapperBatchOperation<string>> {
// we write the index entry into a sublevel-partition of tenantPartition.
// putting each index entry within a sublevel allows the levelDB system to calculate a gt minKey and lt maxKey for each of the properties
// this prevents them from clashing, especially when iterating in reverse without iterating through other properties.
const tenantPartition = await this.db.partition(tenant);
const indexPartitionName = IndexLevel.getIndexPartitionName(indexName);
const partitionOperation = tenantPartition.createPartitionOperation(indexPartitionName, operation);
return partitionOperation;
}
/**
* Wraps the given operation as an operation for the messageCid to indexes lookup partition.
*/
private async createOperationForIndexesLookupPartition(tenant: string, operation: LevelWrapperBatchOperation<string>)
: Promise<LevelWrapperBatchOperation<string>> {
const tenantPartition = await this.db.partition(tenant);
const partitionOperation = tenantPartition.createPartitionOperation(INDEX_SUBLEVEL_NAME, operation);
return partitionOperation;
}
private static getIndexPartitionName(indexName: string): string {
// we create index partition names in __${indexName}__ wrapping so they do not clash with other sublevels that are created for other purposes.
return `__${indexName}__`;
}
/**
* Gets the index partition of the given indexName.
*/
private async getIndexPartition(tenant: string, indexName: string): Promise<LevelWrapper<string>> {
const indexPartitionName = IndexLevel.getIndexPartitionName(indexName);
return (await this.db.partition(tenant)).partition(indexPartitionName);
}
/**
* Gets the messageCid to indexes lookup partition.
*/
private async getIndexesLookupPartition(tenant: string): Promise<LevelWrapper<string>> {
return (await this.db.partition(tenant)).partition(INDEX_SUBLEVEL_NAME);
}
/**
* Queries the index for items that match the filters. If no filters are provided, all items are returned.
*
* @param filters Array of filters that are treated as an OR query.
* @param queryOptions query options for sort and pagination, requires at least `sortProperty`. The default sort direction is ascending.
* @param options IndexLevelOptions that include an AbortSignal.
* @returns {IndexedItem[]} an array of `IndexedItem` that match the given filters.
*/
async query(tenant: string, filters: Filter[], queryOptions: QueryOptions, options?: IndexLevelOptions): Promise<IndexedItem[]> {
// check if we should query using in-memory paging or iterator paging
if (IndexLevel.shouldQueryWithInMemoryPaging(filters, queryOptions)) {
return this.queryWithInMemoryPaging(tenant, filters, queryOptions, options);
}
return this.queryWithIteratorPaging(tenant, filters, queryOptions, options);
}
/**
* Queries the sort property index for items that match the filters. If no filters are provided, all items are returned.
* This query is a linear iterator over the sorted index, checking each item for a match.
* If a cursor is provided it starts the iteration from the cursor point.
*/
async queryWithIteratorPaging(
tenant: string,
filters: Filter[],
queryOptions: QueryOptions,
options?: IndexLevelOptions
): Promise<IndexedItem[]> {
const { cursor: queryCursor , limit } = queryOptions;
// if there is a cursor we fetch the starting key given the sort property, otherwise we start from the beginning of the index.
const startKey = queryCursor ? this.createStartingKeyFromCursor(queryCursor) : '';
const matches: IndexedItem[] = [];
for await ( const item of this.getIndexIterator(tenant, startKey, queryOptions, options)) {
if (limit !== undefined && limit === matches.length) {
break;
}
const { indexes } = item;
if (FilterUtility.matchAnyFilter(indexes, filters)) {
matches.push(item);
}
}
return matches;
}
/**
* Creates an AsyncGenerator that returns each sorted index item given a specific sortProperty.
* If a cursor is passed, the starting value (gt or lt) is derived from that.
*/
private async * getIndexIterator(
tenant: string, startKey:string, queryOptions: QueryOptions, options?: IndexLevelOptions
): AsyncGenerator<IndexedItem> {
const { sortProperty, sortDirection = SortDirection.Ascending, cursor } = queryOptions;
const iteratorOptions: LevelWrapperIteratorOptions<string> = {
gt: startKey
};
// if we are sorting in descending order we can iterate in reverse.
if (sortDirection === SortDirection.Descending) {
iteratorOptions.reverse = true;
// if a cursor is provided and we are sorting in descending order, the startKey should be the upper bound.
if (cursor !== undefined) {
iteratorOptions.lt = startKey;
delete iteratorOptions.gt;
}
}
const sortPartition = await this.getIndexPartition(tenant, sortProperty);
for await (const [ _, val ] of sortPartition.iterator(iteratorOptions, options)) {
const { indexes, messageCid } = JSON.parse(val);
yield { indexes, messageCid };
}
}
/**
* Creates the starting point for a LevelDB query given an messageCid as a cursor and the indexed property.
* Used as (gt) for ascending queries, or (lt) for descending queries.
*/
private createStartingKeyFromCursor(cursor: PaginationCursor): string {
const { messageCid , value } = cursor;
return IndexLevel.keySegmentJoin(IndexLevel.encodeValue(value), messageCid);
}
/**
* Returns a PaginationCursor using the last item of a given array of IndexedItems.
* If the given array is empty, undefined is returned.
*
* @throws {DwnError} if the sort property or cursor value is invalid.
*/
static createCursorFromLastArrayItem(items: IndexedItem[], sortProperty: string): PaginationCursor | undefined {
if (items.length > 0) {
return this.createCursorFromItem(items.at(-1)!, sortProperty);
}
}
/**
* Creates a PaginationCursor from a given IndexedItem and sortProperty.
*
* @throws {DwnError} if the sort property or cursor value is invalid.
*/
static createCursorFromItem(item: IndexedItem, sortProperty: string): PaginationCursor {
const { messageCid , indexes } = item;
const value = indexes[sortProperty];
if (value === undefined) {
throw new DwnError(DwnErrorCode.IndexInvalidCursorSortProperty, `the sort property '${sortProperty}' is not defined within the given item.`);
}
// we only support cursors for string or number types
if (typeof value === 'boolean' || Array.isArray(value)) {
throw new DwnError(
DwnErrorCode.IndexInvalidCursorValueType,
`only string or number values are supported for cursors, a(n) ${typeof value} was given.`
);
}
return { messageCid , value };
}
/**
* Queries the provided searchFilters asynchronously, returning results that match the matchFilters.
*
* @param filters the filters passed to the parent query.
* @param searchFilters the modified filters used for the LevelDB query to search for a subset of items to match against.
*
* @throws {DwnErrorCode.IndexLevelInMemoryInvalidSortProperty} if an invalid sort property is provided.
*/
async queryWithInMemoryPaging(
tenant: string,
filters: Filter[],
queryOptions: QueryOptions,
options?: IndexLevelOptions
): Promise<IndexedItem[]> {
const { sortProperty, sortDirection = SortDirection.Ascending, cursor: queryCursor, limit } = queryOptions;
// we get the cursor start key here so that we match the failing behavior of `queryWithIteratorPaging`
const cursorStartingKey = queryCursor ? this.createStartingKeyFromCursor(queryCursor) : undefined;
// we create a matches map so that we can short-circuit matched items within the async single query below.
const matches:Map<string, IndexedItem> = new Map();
// If the filter is empty, we just give it an empty filter so that we can iterate over all the items later in executeSingleFilterQuery().
// We could do the iteration here, but it would be duplicating the same logic, so decided to just setup the data structure here.
if (filters.length === 0) {
filters = [{}];
}
try {
await Promise.all(filters.map(filter => {
return this.executeSingleFilterQuery(tenant, filter, sortProperty, matches, options );
}));
} catch (error) {
if ((error as DwnError).code === DwnErrorCode.IndexInvalidSortPropertyInMemory) {
// return empty results if the sort property is invalid.
return [];
}
}
const sortedValues = [...matches.values()].sort((a,b) => this.sortItems(a,b, sortProperty, sortDirection));
const start = cursorStartingKey !== undefined ? this.findCursorStartingIndex(sortedValues, sortDirection, sortProperty, cursorStartingKey) : 0;
if (start < 0) {
// if the provided cursor does not come before any of the results, we return no results
return [];
}
const end = limit !== undefined ? start + limit: undefined;
return sortedValues.slice(start, end);
}
/**
* Execute a filtered query against a single filter and return all results.
*/
private async executeSingleFilterQuery(
tenant: string,
filter: Filter,
sortProperty: string,
matches: Map<string, IndexedItem>,
levelOptions?: IndexLevelOptions
): Promise<void> {
// Note: We have an array of Promises in order to support OR (anyOf) matches when given a list of accepted values for a property
const filterPromises: Promise<IndexedItem[]>[] = [];
// If the filter is empty, then we just iterate over one of the indexes that contains all the records and return all items.
if (isEmptyObject(filter)) {
const getAllItemsPromise = this.getAllItems(tenant, sortProperty);
filterPromises.push(getAllItemsPromise);
}
// else the filter is not empty
const searchFilter = FilterSelector.reduceFilter(filter);
for (const propertyName in searchFilter) {
const propertyFilter = searchFilter[propertyName];
// We will find the union of these many individual queries later.
if (FilterUtility.isEqualFilter(propertyFilter)) {
// propertyFilter is an EqualFilter, meaning it is a non-object primitive type
const exactMatchesPromise = this.filterExactMatches(tenant, propertyName, propertyFilter, levelOptions);
filterPromises.push(exactMatchesPromise);
} else if (FilterUtility.isOneOfFilter(propertyFilter)) {
// `propertyFilter` is a OneOfFilter
// Support OR matches by querying for each values separately, then adding them to the promises array.
for (const propertyValue of new Set(propertyFilter)) {
const exactMatchesPromise = this.filterExactMatches(tenant, propertyName, propertyValue, levelOptions);
filterPromises.push(exactMatchesPromise);
}
} else if (FilterUtility.isRangeFilter(propertyFilter)) {
// `propertyFilter` is a `RangeFilter`
const rangeMatchesPromise = this.filterRangeMatches(tenant, propertyName, propertyFilter, levelOptions);
filterPromises.push(rangeMatchesPromise);
}
}
// acting as an OR match for the property, any of the promises returning a match will be treated as a property match
for (const promise of filterPromises) {
const indexItems = await promise;
// reminder: the promise returns a list of IndexedItem satisfying a particular property match
for (const indexedItem of indexItems) {
// short circuit: if a data is already included to the final matched key set (by a different `Filter`),
// no need to evaluate if the data satisfies this current filter being evaluated
// otherwise check that the item is a match.
if (matches.has(indexedItem.messageCid) || !FilterUtility.matchFilter(indexedItem.indexes, filter)) {
continue;
}
// ensure that each matched item has the sortProperty, otherwise fail the entire query.
if (indexedItem.indexes[sortProperty] === undefined) {
throw new DwnError(DwnErrorCode.IndexInvalidSortPropertyInMemory, `invalid sort property ${sortProperty}`);
}
matches.set(indexedItem.messageCid, indexedItem);
}
}
}
private async getAllItems(tenant: string, sortProperty: string): Promise<IndexedItem[]> {
const filterPartition = await this.getIndexPartition(tenant, sortProperty);
const items: IndexedItem[] = [];
for await (const [ _key, value ] of filterPartition.iterator()) {
items.push(JSON.parse(value) as IndexedItem);
}
return items;
}
/**
* Returns items that match the exact property and value.
*/
private async filterExactMatches(
tenant:string,
propertyName: string,
propertyValue: EqualFilter,
options?: IndexLevelOptions
): Promise<IndexedItem[]> {
const matchPrefix = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(propertyValue));
const iteratorOptions: LevelWrapperIteratorOptions<string> = {
gt: matchPrefix
};
const filterPartition = await this.getIndexPartition(tenant, propertyName);
const matches: IndexedItem[] = [];
for await (const [ key, value ] of filterPartition.iterator(iteratorOptions, options)) {
// immediately stop if we arrive at an index that contains a different property value
if (!key.startsWith(matchPrefix)) {
break;
}
matches.push(JSON.parse(value) as IndexedItem);
}
return matches;
}
/**
* Returns items that match the range filter.
*/
private async filterRangeMatches(
tenant: string,
propertyName: string,
rangeFilter: RangeFilter,
options?: IndexLevelOptions
): Promise<IndexedItem[]> {
const iteratorOptions: LevelWrapperIteratorOptions<string> = {};
for (const comparator in rangeFilter) {
const comparatorName = comparator as keyof RangeFilter;
iteratorOptions[comparatorName] = IndexLevel.encodeValue(rangeFilter[comparatorName]!);
}
// if there is no lower bound specified (`gt` or `gte`), we need to iterate from the upper bound,
// so that we will iterate over all the matches before hitting mismatches.
if (iteratorOptions.gt === undefined && iteratorOptions.gte === undefined) {
iteratorOptions.reverse = true;
}
const matches: IndexedItem[] = [];
const filterPartition = await this.getIndexPartition(tenant, propertyName);
for await (const [ key, value ] of filterPartition.iterator(iteratorOptions, options)) {
// if "greater-than" is specified, skip all keys that contains the exact value given in the "greater-than" condition
if ('gt' in rangeFilter && this.extractIndexValueFromKey(key) === IndexLevel.encodeValue(rangeFilter.gt!)) {
continue;
}
matches.push(JSON.parse(value) as IndexedItem);
}
if ('lte' in rangeFilter) {
// When `lte` is used, we must also query the exact match explicitly because the exact match will not be included in the iterator above.
// This is due to the extra data appended to the (property + value) key prefix, e.g.
// the key '"2023-05-25T11:22:33.000000Z"\u0000bayfreigu....'
// would be considered greater than `lte` value in { lte: '"2023-05-25T11:22:33.000000Z"' } iterator options,
// thus would not be included in the iterator even though we'd like it to be.
for (const item of await this.filterExactMatches(tenant, propertyName, rangeFilter.lte as EqualFilter, options)) {
matches.push(item);
}
}
return matches;
}
/**
* Sorts Items lexicographically in ascending or descending order given a specific indexName, using the messageCid as a tie breaker.
* We know the indexes include the indexName and they are only of string or number type and not Arrays or booleans.
* because they have already been checked within executeSingleFilterQuery.
*/
private sortItems(itemA: IndexedItem, itemB: IndexedItem, indexName: string, direction: SortDirection): number {
const itemAValue = itemA.indexes[indexName] as string | number;
const itemBValue = itemB.indexes[indexName] as string | number;
const aCompareValue = IndexLevel.encodeValue(itemAValue) + itemA.messageCid;
const bCompareValue = IndexLevel.encodeValue(itemBValue) + itemB.messageCid;
return direction === SortDirection.Ascending ?
lexicographicalCompare(aCompareValue, bCompareValue) :
lexicographicalCompare(bCompareValue, aCompareValue);
}
/**
* Find the starting position for pagination within the IndexedItem array.
* Returns the index of the first item found which is either greater than or less than the given cursor, depending on sort order.
*/
private findCursorStartingIndex(items: IndexedItem[], sortDirection: SortDirection, sortProperty: string, cursorStartingKey: string): number {
const firstItemAfterCursor = (item: IndexedItem): boolean => {
const { messageCid, indexes } = item;
const sortValue = indexes[sortProperty] as string | number;
const itemCompareValue = IndexLevel.keySegmentJoin(IndexLevel.encodeValue(sortValue), messageCid);
return sortDirection === SortDirection.Ascending ?
itemCompareValue > cursorStartingKey :
itemCompareValue < cursorStartingKey;
};
return items.findIndex(firstItemAfterCursor);
}
/**
* Gets the indexes given an messageCid. This is a reverse lookup to construct starting keys, as well as deleting indexed items.
*/
private async getIndexes(tenant: string, messageCid: string): Promise<KeyValues|undefined> {
const indexesLookupPartition = await this.getIndexesLookupPartition(tenant);
const serializedIndexes = await indexesLookupPartition.get(messageCid);
if (serializedIndexes === undefined) {
// invalid messageCid
return;
}
return JSON.parse(serializedIndexes) as KeyValues;
}
/**
* Given a key from an indexed partitioned property key.
* ex:
* key: '"2023-05-25T11:22:33.000000Z"\u0000bayfreigu....'
* returns "2023-05-25T11:22:33.000000Z"
*/
private extractIndexValueFromKey(key: string): string {
const [value] = key.split(IndexLevel.delimiter);
return value;
}
/**
* Joins the given values using the `\x00` (\u0000) character.
*/
private static delimiter = `\x00`;
private static keySegmentJoin(...values: string[]): string {
return values.join(IndexLevel.delimiter);
}
/**
* Encodes a numerical value as a string for lexicographical comparison.
* If the number is positive it simply pads it with leading zeros.
* ex.: input: 1024 => "0000000000001024"
* input: -1024 => "!9007199254739967"
*
* @param value the number to encode.
* @returns a string representation of the number.
*/
static encodeNumberValue(value: number): string {
const NEGATIVE_OFFSET = Number.MAX_SAFE_INTEGER;
const NEGATIVE_PREFIX = '!'; // this will be sorted below positive numbers lexicographically
const PADDING_LENGTH = String(Number.MAX_SAFE_INTEGER).length;
const prefix: string = value < 0 ? NEGATIVE_PREFIX : '';
const offset: number = value < 0 ? NEGATIVE_OFFSET : 0;
return prefix + String(value + offset).padStart(PADDING_LENGTH, '0');
}
/**
* Encodes an indexed value to a string
*
* NOTE: we currently only use this for strings, numbers and booleans.
*/
static encodeValue(value: string | number | boolean): string {
switch (typeof value) {
case 'number':
return this.encodeNumberValue(value);
default:
return JSON.stringify(value);
}
}
private static shouldQueryWithInMemoryPaging(filters: Filter[], queryOptions: QueryOptions): boolean {
for (const filter of filters) {
if (!IndexLevel.isFilterConcise(filter, queryOptions)) {
return false;
}
}
// only use in-memory paging if all filters are concise
return true;
}
public static isFilterConcise(filter: Filter, queryOptions: QueryOptions): boolean {
// if there is a specific recordId in the filter, return true immediately.
if (filter.recordId !== undefined) {
return true;
}
// unless a recordId is present, if there is a cursor we never use in memory paging
if (queryOptions.cursor !== undefined) {
return false;
}
// NOTE: remaining conditions will not have cursor
if (
filter.protocolPath !== undefined ||
filter.contextId !== undefined ||
filter.parentId !== undefined ||
filter.schema !== undefined
) {
return true;
}
// all else
return false;
}
}
@@ -0,0 +1,272 @@
import type { AbstractBatchOperation, AbstractDatabaseOptions, AbstractIteratorOptions, AbstractLevel } from 'abstract-level';
import { executeUnlessAborted } from '../utils/abort.js';
import { Level } from 'level';
export type CreateLevelDatabaseOptions<V> = AbstractDatabaseOptions<string, V>;
export type LevelDatabase<V> = AbstractLevel<string | Buffer | Uint8Array, string, V>;
export async function createLevelDatabase<V>(location: string, options?: CreateLevelDatabaseOptions<V>): Promise<LevelDatabase<V>> {
// Only import `'level'` when it's actually necessary (i.e. only when the default `createLevelDatabase` is used).
// Overriding `createLevelDatabase` will prevent this from happening.
return new Level(location, { ...options, keyEncoding: 'utf8' });
}
export interface LevelWrapperOptions {
signal?: AbortSignal;
}
export type LevelWrapperBatchOperation<V> = AbstractBatchOperation<LevelDatabase<V>, string, V>;
export type LevelWrapperIteratorOptions<V> = AbstractIteratorOptions<string, V>;
// `Level` works in Node.js 12+ and Electron 5+ on Linux, Mac OS, Windows and FreeBSD, including any
// future Node.js and Electron release thanks to Node-API, including ARM platforms like Raspberry Pi
// and Android, as well as in Chrome, Firefox, Edge, Safari, iOS Safari and Chrome for Android.
export class LevelWrapper<V> {
config: LevelWrapperConfig<V>;
db: LevelDatabase<V>;
/**
* @param config.location - must be a directory path (relative or absolute) where `Level`` will
* store its files, or in browsers, the name of the {@link https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase `IDBDatabase`}
* to be opened.
*/
constructor(config: LevelWrapperConfig<V>, db?: LevelDatabase<V>) {
this.config = {
createLevelDatabase,
...config
};
this.db = db!;
}
async open(): Promise<void> {
await this.createLevelDatabase();
// `db.open()` is automatically called by the database constructor. We may need to call it explicitly
// in order to explicitly catch an error that would otherwise not surface until another method
// like `db.get()` is called. Once `db.open()` has then been called, any read & write
// operations will again be queued internally until opening has finished.
switch (this.db.status) {
// If db is open, we are done.
case 'open':
return;
// If db is still opening, wait until the 'open' event is emitted
case 'opening':
return new Promise((resolve) => {
this.db.once('open', resolve);
});
// If db is closing, wait until it is closed then await `db.open()`
case 'closing':
return new Promise((resolve, reject) => {
const onClosed = (): void => {
// Make sure that errors from `db.open()` propogate up
this.db.open().then(resolve).catch(reject);;
};
this.db.once('closed', onClosed);
});
// If db is closed, `db.open`
case 'closed':
return this.db.open();
}
}
async close(): Promise<void> {
if (!this.db) {
return;
}
switch (this.db.status) {
// If db is open, we `db.close`.
case 'open':
return this.db.close();
// If db is still opening, wait until it is open then await `db.close()`
case 'opening':
return new Promise((resolve, reject) => {
const onOpen = (): void => {
// Make sure that errors from `db.open()` propogate up
this.db.close().then(resolve).catch(reject);;
};
this.db.once('open', onOpen);
});
// If db is closing, wait until the 'closed' event is emitted
case 'closing':
return new Promise((resolve) => {
this.db.once('closed', resolve);
});
// If db is closed, we are done
case 'closed':
return;
}
}
async partition(name: string): Promise<LevelWrapper<V>> {
await this.createLevelDatabase();
return new LevelWrapper(this.config, this.db.sublevel(name, {
keyEncoding : 'utf8',
valueEncoding : this.config.valueEncoding
}));
}
async get(key: string, options?: LevelWrapperOptions): Promise<V|undefined>{
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
try {
const value = await executeUnlessAborted(this.db.get(String(key)), options?.signal);
return value;
} catch (error) {
const e = error as { code: string };
// `Level`` throws an error if the key is not present. Return `undefined` in this case.
if (e.code === 'LEVEL_NOT_FOUND') {
return undefined;
} else {
throw error;
}
}
}
async has(key: string, options?: LevelWrapperOptions): Promise<boolean> {
return !! await this.get(key, options);
}
async * keys(options?: LevelWrapperOptions): AsyncGenerator<string> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
for await (const key of this.db.keys()) {
options?.signal?.throwIfAborted();
yield key;
}
}
async * iterator(iteratorOptions?: LevelWrapperIteratorOptions<V>, options?: LevelWrapperOptions): AsyncGenerator<[string, V]> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
for await (const entry of this.db.iterator(iteratorOptions!)) {
options?.signal?.throwIfAborted();
yield entry;
}
}
async put(key: string, value: V, options?: LevelWrapperOptions): Promise<void> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
return executeUnlessAborted(this.db.put(String(key), value), options?.signal);
}
async delete(key: string, options?: LevelWrapperOptions): Promise<void> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
return executeUnlessAborted(this.db.del(String(key)), options?.signal);
}
async isEmpty(options?: LevelWrapperOptions): Promise<boolean> {
for await (const _key of this.keys(options)) {
return false;
}
return true;
}
async clear(): Promise<void> {
await this.createLevelDatabase();
await this.db.clear();
await this.compactUnderlyingStorage();
}
async batch(operations: Array<LevelWrapperBatchOperation<V>>, options?: LevelWrapperOptions): Promise<void> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
return executeUnlessAborted(this.db.batch(operations), options?.signal);
}
/**
* Wraps the given LevelWrapperBatchOperation as an operation for the specified partition.
*/
createPartitionOperation(partitionName: string, operation: LevelWrapperBatchOperation<V>): LevelWrapperBatchOperation<V> {
return { ...operation, sublevel: this.db.sublevel(partitionName, {
keyEncoding : 'utf8',
valueEncoding : this.config.valueEncoding
}) };
}
private async compactUnderlyingStorage(options?: LevelWrapperOptions): Promise<void> {
options?.signal?.throwIfAborted();
await executeUnlessAborted(this.createLevelDatabase(), options?.signal);
const range = this.sublevelRange;
if (!range) {
return;
}
// additional methods are only available on the root API instance
const root = this.root;
if (root.db.supports.additionalMethods.compactRange) {
return executeUnlessAborted((root.db as any).compactRange?.(...range), options?.signal);
}
}
/**
* Gets the min and max key value of this partition.
*/
private get sublevelRange(): [ string, string ] | undefined {
const prefix = (this.db as any).prefix as string;
if (!prefix) {
return undefined;
}
// derive an exclusive `maxKey` by changing the last prefix character to the immediate succeeding character in unicode
// (which matches how `abstract-level` creates a `boundary`)
const maxKey = prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
const minKey = prefix;
return [minKey, maxKey];
}
private get root(): LevelWrapper<V> {
let db = this.db;
for (const parent = (db as any).db; parent && parent !== db; ) {
db = parent;
}
return new LevelWrapper(this.config, db);
}
private async createLevelDatabase(): Promise<void> {
this.db ??= await this.config.createLevelDatabase!<V>(this.config.location, {
keyEncoding : 'utf8',
valueEncoding : this.config.valueEncoding
});
}
}
type LevelWrapperConfig<V> = CreateLevelDatabaseOptions<V> & {
location: string,
createLevelDatabase?: typeof createLevelDatabase,
};
@@ -0,0 +1,195 @@
import type { Filter, KeyValues, PaginationCursor, QueryOptions } from '../types/query-types.js';
import type { GenericMessage, MessageSort, Pagination } from '../types/message-types.js';
import type { MessageStore, MessageStoreOptions } from '../types/message-store.js';
import * as block from 'multiformats/block';
import * as cbor from '@ipld/dag-cbor';
import { BlockstoreLevel } from './blockstore-level.js';
import { Cid } from '../utils/cid.js';
import { CID } from 'multiformats/cid';
import { createLevelDatabase } from './level-wrapper.js';
import { executeUnlessAborted } from '../utils/abort.js';
import { IndexLevel } from './index-level.js';
import { Message } from '../core/message.js';
import { sha256 } from 'multiformats/hashes/sha2';
import { SortDirection } from '../types/query-types.js';
/**
* A simple implementation of {@link MessageStore} that works in both the browser and server-side.
* Leverages LevelDB under the hood.
*/
export class MessageStoreLevel implements MessageStore {
config: MessageStoreLevelConfig;
blockstore: BlockstoreLevel;
index: IndexLevel;
/**
* @param {MessageStoreLevelConfig} config
* @param {string} config.blockstoreLocation - must be a directory path (relative or absolute) where
* LevelDB will store its files, or in browsers, the name of the
* {@link https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase IDBDatabase} to be opened.
* @param {string} config.indexLocation - same as config.blockstoreLocation
*/
constructor(config: MessageStoreLevelConfig = {}) {
this.config = {
blockstoreLocation : 'MESSAGESTORE',
indexLocation : 'INDEX',
createLevelDatabase,
...config
};
this.blockstore = new BlockstoreLevel({
location : this.config.blockstoreLocation!,
createLevelDatabase : this.config.createLevelDatabase,
});
this.index = new IndexLevel({
location : this.config.indexLocation!,
createLevelDatabase : this.config.createLevelDatabase,
});
}
async open(): Promise<void> {
await this.blockstore.open();
await this.index.open();
}
async close(): Promise<void> {
await this.blockstore.close();
await this.index.close();
}
async get(tenant: string, cidString: string, options?: MessageStoreOptions): Promise<GenericMessage | undefined> {
options?.signal?.throwIfAborted();
const partition = await executeUnlessAborted(this.blockstore.partition(tenant), options?.signal);
const cid = CID.parse(cidString);
const bytes = await partition.get(cid, options);
if (!bytes) {
return undefined;
}
const decodedBlock = await executeUnlessAborted(block.decode({ bytes, codec: cbor, hasher: sha256 }), options?.signal);
const message = decodedBlock.value as GenericMessage;
return message;
}
async query(
tenant: string,
filters: Filter[],
messageSort?: MessageSort,
pagination?: Pagination,
options?: MessageStoreOptions
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor}> {
options?.signal?.throwIfAborted();
// creates the query options including sorting and pagination.
// this adds 1 to the limit if provided, that way we can check to see if there are additional results and provide a return cursor.
const queryOptions = MessageStoreLevel.buildQueryOptions(messageSort, pagination);
const results = await this.index.query(tenant, filters, queryOptions, options);
let cursor: PaginationCursor | undefined;
// checks to see if the returned results are greater than the limit, which would indicate additional results.
if (pagination?.limit !== undefined && pagination.limit < results.length) {
// has additional records, remove last record and set cursor
results.splice(-1);
// set cursor to the last item remaining after the spliced result.
cursor = IndexLevel.createCursorFromLastArrayItem(results, queryOptions.sortProperty);
}
const messages: GenericMessage[] = [];
for (let i = 0; i < results.length; i++) {
const { messageCid } = results[i];
const message = await this.get(tenant, messageCid, options);
if (message) { messages.push(message); }
}
return { messages, cursor };
}
/**
* Builds the IndexLevel QueryOptions object given MessageStore sort and pagination parameters.
*/
static buildQueryOptions(messageSort: MessageSort = {}, pagination: Pagination = {}): QueryOptions {
let { limit, cursor } = pagination;
const { dateCreated, datePublished, messageTimestamp } = messageSort;
let sortDirection = SortDirection.Ascending; // default
// `keyof MessageSort` = name of all properties of `MessageSort` defaults to messageTimestamp
let sortProperty: keyof MessageSort = 'messageTimestamp';
// set the sort property
if (dateCreated !== undefined) {
sortProperty = 'dateCreated';
} else if (datePublished !== undefined) {
sortProperty = 'datePublished';
} else if (messageTimestamp !== undefined) {
sortProperty = 'messageTimestamp';
}
if (messageSort[sortProperty] !== undefined) {
sortDirection = messageSort[sortProperty]!;
}
// we add one more to the limit to determine whether there are additional results and to return a cursor.
if (limit !== undefined && limit > 0) {
limit = limit + 1;
}
return { sortDirection, sortProperty, limit, cursor };
}
async delete(tenant: string, cidString: string, options?: MessageStoreOptions): Promise<void> {
options?.signal?.throwIfAborted();
const partition = await executeUnlessAborted(this.blockstore.partition(tenant), options?.signal);
const cid = CID.parse(cidString);
await partition.delete(cid, options);
await this.index.delete(tenant, cidString, options);
}
async put(
tenant: string,
message: GenericMessage,
indexes: KeyValues,
options?: MessageStoreOptions
): Promise<void> {
options?.signal?.throwIfAborted();
const partition = await executeUnlessAborted(this.blockstore.partition(tenant), options?.signal);
const encodedMessageBlock = await executeUnlessAborted(block.encode({ value: message, codec: cbor, hasher: sha256 }), options?.signal);
// MessageStore data may contain `encodedData` which is not taken into account when calculating the blockCID as it is optional data.
const messageCid = Cid.parseCid(await Message.getCid(message));
await partition.put(messageCid, encodedMessageBlock.bytes, options);
const messageCidString = messageCid.toString();
await this.index.put(tenant, messageCidString, indexes, options);
}
/**
* deletes everything in the underlying blockstore and indices.
*/
async clear(): Promise<void> {
await this.blockstore.clear();
await this.index.clear();
}
}
type MessageStoreLevelConfig = {
blockstoreLocation?: string,
indexLocation?: string,
createLevelDatabase?: typeof createLevelDatabase,
};
@@ -0,0 +1,172 @@
import type { DataStore } from '../types/data-store.js';
import type { EventLog } from '../types/event-log.js';
import type { GenericMessage } from '../types/message-types.js';
import type { MessageStore } from '../types/message-store.js';
import type { RecordsDeleteMessage, RecordsQueryReplyEntry, RecordsWriteMessage } from '../types/records-types.js';
import { DwnConstant } from '../core/dwn-constant.js';
import { Message } from '../core/message.js';
import { Records } from '../utils/records.js';
import { RecordsWrite } from '../interfaces/records-write.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
/**
* A class that provides an abstraction for the usage of MessageStore, DataStore, and EventLog.
*/
export class StorageController {
/**
* Deletes the data referenced by the given message if needed.
* @param message The message to check if the data it references should be deleted.
*/
private static async deleteFromDataStoreIfNeeded(
dataStore: DataStore,
tenant: string,
message: GenericMessage,
newestMessage: GenericMessage
): Promise<void> {
if (message.descriptor.method !== DwnMethodName.Write) {
return;
}
const recordsWriteMessage = message as RecordsWriteMessage;
// Optional short-circuit optimization to avoid unnecessary data store call since the data should be encoded with the message itself in this case,
// but data store call is a no-op thus code still works correctly even if this short-circuit is removed.
if (recordsWriteMessage.descriptor.dataSize <= DwnConstant.maxDataSizeAllowedToBeEncoded) {
return;
}
// We must still keep the data if the newest message still references the same data.
if (recordsWriteMessage.descriptor.dataCid === (newestMessage as RecordsWriteMessage).descriptor.dataCid) {
return;
}
// Else we delete the data from the data store.
await dataStore.delete(tenant, recordsWriteMessage.recordId, recordsWriteMessage.descriptor.dataCid);
}
/**
* Purges (permanent hard-delete) all descendant's data of the given `recordId`.
*/
public static async purgeRecordDescendants(
tenant: string,
recordId: string,
messageStore: MessageStore,
dataStore: DataStore,
eventLog: EventLog
): Promise<void> {
const filter = {
interface : DwnInterfaceName.Records,
parentId : recordId
};
const { messages: childMessages } = await messageStore.query(tenant, [filter]);
// group the child messages by `recordId`
const recordIdToMessagesMap = new Map<string, GenericMessage[]>();
for (const message of childMessages) {
// get the recordId
let recordId;
if (Records.isRecordsWrite(message)) {
recordId = message.recordId;
} else {
recordId = (message as RecordsDeleteMessage).descriptor.recordId;
}
if (!recordIdToMessagesMap.has(recordId)) {
recordIdToMessagesMap.set(recordId, []);
}
recordIdToMessagesMap.get(recordId)!.push(message);
}
// purge all child's descendants first
for (const childRecordId of recordIdToMessagesMap.keys()) {
// purge the child's descendent messages first
await StorageController.purgeRecordDescendants(tenant, childRecordId, messageStore, dataStore, eventLog);
}
// then purge the child messages themselves
for (const childRecordId of recordIdToMessagesMap.keys()) {
await StorageController.purgeRecordMessages(tenant, recordIdToMessagesMap.get(childRecordId)!, messageStore, dataStore, eventLog);
}
}
/**
* Purges (permanent hard-delete) all messages of the SAME `recordId` given and their associated data and events.
* Assumes that the given `recordMessages` are all of the same `recordId`.
*/
private static async purgeRecordMessages(
tenant: string,
recordMessages: GenericMessage[],
messageStore: MessageStore,
dataStore: DataStore,
eventLog: EventLog
): Promise<void> {
// delete the data from the data store first so no chance of orphaned data (not having a message referencing it) in case of server crash
// NOTE: only the `RecordsWrite` with latest timestamp can possibly have data associated with it so we do this filtering as an optimization
// NOTE: however there could still be no data associated with the `RecordsWrite` with newest timestamp, because either:
// 1. the data is encoded with the message itself; or
// 2. the newest `RecordsWrite` may not be the "true" latest state due to:
// a. sync has yet to write the latest `RecordsWrite`; or
// b. `recordMessages` maybe an incomplete page of results if the caller uses the paging in its query
// Calling dataStore.delete() is a no-op if the data is not found, so we are safe to call it redundantly.
const recordsWrites = recordMessages.filter((message) => message.descriptor.method === DwnMethodName.Write);
const newestRecordsWrite = (await Message.getNewestMessage(recordsWrites)) as RecordsWriteMessage;
await dataStore.delete(tenant, newestRecordsWrite.recordId, newestRecordsWrite.descriptor.dataCid);
// then delete all events associated with the record messages before deleting the messages so we don't have orphaned events
const messageCids = await Promise.all(recordMessages.map((message) => Message.getCid(message)));
await eventLog.deleteEventsByCid(tenant, messageCids);
// finally delete all record messages
await Promise.all(messageCids.map((messageCid) => messageStore.delete(tenant, messageCid)));
}
/**
* Deletes all messages in `existingMessages` that are older than the `newestMessage` in the given tenant,
* but keep the initial write write for future processing by ensuring its `isLatestBaseState` index is "false".
*/
public static async deleteAllOlderMessagesButKeepInitialWrite(
tenant: string,
existingMessages: GenericMessage[],
newestMessage: GenericMessage,
messageStore: MessageStore,
dataStore: DataStore,
eventLog: EventLog
): Promise<void> {
const deletedMessageCids: string[] = [];
// NOTE: under normal operation, there should only be at most two existing records per `recordId` (initial + a potential subsequent write/delete),
// but the DWN may crash before `delete()` is called below, so we use a loop as a tactic to clean up lingering data as needed
for (const message of existingMessages) {
const messageIsOld = await Message.isOlder(message, newestMessage);
if (messageIsOld) {
// the easiest implementation here is delete each old messages
// and re-create it with the right index (isLatestBaseState = 'false') if the message is the initial write,
// but there is room for better/more efficient implementation here
await StorageController.deleteFromDataStoreIfNeeded(dataStore, tenant, message, newestMessage);
// delete message from message store
const messageCid = await Message.getCid(message);
await messageStore.delete(tenant, messageCid);
// if the existing message is the initial write
// we actually need to keep it BUT, need to ensure the message is no longer marked as the latest state
const existingMessageIsInitialWrite = await RecordsWrite.isInitialWrite(message);
if (existingMessageIsInitialWrite) {
const existingRecordsWrite = await RecordsWrite.parse(message as RecordsWriteMessage);
const isLatestBaseState = false;
const indexes = await existingRecordsWrite.constructIndexes(isLatestBaseState);
const writeMessage = message as RecordsQueryReplyEntry;
delete writeMessage.encodedData;
await messageStore.put(tenant, writeMessage, indexes);
} else {
const messageCid = await Message.getCid(message);
deletedMessageCids.push(messageCid);
}
}
await eventLog.deleteEventsByCid(tenant, deletedMessageCids);
}
}
}
@@ -0,0 +1,16 @@
/**
* A generalized cache interface.
* The motivation behind this interface is so that code that depend on the cache can remain independent to the underlying implementation.
*/
export interface Cache {
/**
* Sets a key-value pair. Does not throw error.
*/
set(key: string, value: any): Promise<void>;
/**
* Gets the value corresponding to the given key.
* @returns value stored corresponding to the given key; `undefined` if key is not found or expired
*/
get(key: string): Promise<any | undefined>;
}
@@ -0,0 +1,64 @@
import type { Readable } from 'readable-stream';
/**
* The interface that defines how to store and fetch data associated with a message.
*/
export interface DataStore {
/**
* Opens a connection to the underlying store.
*/
open(): Promise<void>;
/**
* Closes the connection to the underlying store.
*/
close(): Promise<void>;
/**
* Stores the given data.
* @param recordId The logical ID of the record that references the data.
* @param dataCid The IPFS CID of the data.
*/
put(tenant: string, recordId: string, dataCid: string, dataStream: Readable): Promise<DataStorePutResult>;
/**
* Fetches the specified data.
* @param recordId The logical ID of the record that references the data.
* @param dataCid The IPFS CID of the data.
* @returns the data size and data stream if found, otherwise `undefined`.
*/
get(tenant: string, recordId: string, dataCid: string): Promise<DataStoreGetResult | undefined>;
/**
* Deletes the specified data. No-op if the data does not exist.
* @param recordId The logical ID of the record that references the data.
* @param dataCid The IPFS CID of the data.
*/
delete(tenant: string, recordId: string, dataCid: string): Promise<void>;
/**
* Clears the entire store. Mainly used for testing to cleaning up in test environments.
*/
clear(): Promise<void>;
}
/**
* Result of a data store `put()` method call.
*/
export type DataStorePutResult = {
/**
* The number of bytes of the data stored.
*/
dataSize: number;
};
/**
* Result of a data store `get()` method call if the data exists.
*/
export type DataStoreGetResult = {
/**
* The number of bytes of the data stored.
*/
dataSize: number;
dataStream: Readable;
};
@@ -0,0 +1,52 @@
import type { Filter, KeyValues, PaginationCursor } from './query-types.js';
export interface EventLog {
/**
* opens a connection to the underlying store
*/
open(): Promise<void>;
/**
* closes the connection to the underlying store
*/
close(): Promise<void>;
/**
* adds an event to a tenant's event log
* @param tenant - the tenant's DID
* @param messageCid - the CID of the message
* @param indexes - (key-value pairs) to be included as part of indexing this event.
*/
append(tenant: string, messageCid: string, indexes: KeyValues): Promise<void>
/**
* Retrieves all of a tenant's events that occurred after the cursor provided.
* If no cursor is provided, all events for a given tenant will be returned.
*
* The cursor is a messageCid.
*
* Returns an array of messageCids that represent the events.
*/
getEvents(tenant: string, cursor?: PaginationCursor): Promise<{ events: string[], cursor?: PaginationCursor }>
/**
* retrieves a filtered set of events that occurred after a the cursor provided, accepts multiple filters.
*
* If no cursor is provided, all events for a given tenant and filter combo will be returned.
* The cursor is a messageCid.
*
* Returns an array of messageCids that represent the events.
*/
queryEvents(tenant: string, filters: Filter[], cursor?: PaginationCursor): Promise<{ events: string[], cursor?: PaginationCursor }>
/**
* deletes any events that have any of the messageCids provided
* @returns {Promise<number>} the number of events deleted
*/
deleteEventsByCid(tenant: string, messageCids: Array<string>): Promise<void>
/**
* Clears the entire store. Mainly used for cleaning up in test environment.
*/
clear(): Promise<void>;
}
@@ -0,0 +1,92 @@
import type { MessageEvent } from './subscriptions.js';
import type { AuthorizationModel, GenericMessage, GenericMessageReply, MessageSubscription } from './message-types.js';
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import type { PaginationCursor, RangeCriterion, RangeFilter } from './query-types.js';
/**
* filters used when filtering for any type of Message across interfaces
*/
export type EventsMessageFilter = {
interface?: string;
method?: string;
dateUpdated?: RangeCriterion;
};
/**
* We only allow filtering for events by immutable properties, the omitted properties could be different per subsequent writes.
*/
export type EventsRecordsFilter = {
recipient?: string;
protocol?: string;
protocolPath?: string;
contextId?: string;
schema?: string;
recordId?: string;
parentId?: string;
dataFormat?: string;
dataSize?: RangeFilter;
dateCreated?: RangeCriterion;
};
/**
* A union type of the different types of filters a user can use when issuing an EventsQuery or EventsSubscribe
* TODO: simplify the EventsFilters to only the necessary in order to reduce complexity https://github.com/TBD54566975/dwn-sdk-js/issues/663
*/
export type EventsFilter = EventsMessageFilter | EventsRecordsFilter;
export type EventsGetDescriptor = {
interface: DwnInterfaceName.Events;
method: DwnMethodName.Get;
cursor?: PaginationCursor;
messageTimestamp: string;
};
export type EventsGetMessage = GenericMessage & {
authorization: AuthorizationModel; // overriding `GenericMessage` with `authorization` being required
descriptor: EventsGetDescriptor;
};
export type EventsGetReply = GenericMessageReply & {
entries?: string[];
cursor?: PaginationCursor;
};
export type MessageSubscriptionHandler = (event: MessageEvent) => void;
export type EventsSubscribeMessageOptions = {
subscriptionHandler: MessageSubscriptionHandler;
};
export type EventsSubscribeMessage = {
authorization: AuthorizationModel;
descriptor: EventsSubscribeDescriptor;
};
export type EventsSubscribeReply = GenericMessageReply & {
subscription?: MessageSubscription;
};
export type EventsSubscribeDescriptor = {
interface: DwnInterfaceName.Events;
method: DwnMethodName.Subscribe;
messageTimestamp: string;
filters: EventsFilter[];
};
export type EventsQueryDescriptor = {
interface: DwnInterfaceName.Events;
method: DwnMethodName.Query;
messageTimestamp: string;
filters: EventsFilter[];
cursor?: PaginationCursor;
};
export type EventsQueryMessage = GenericMessage & {
authorization: AuthorizationModel;
descriptor: EventsQueryDescriptor;
};
export type EventsQueryReply = GenericMessageReply & {
entries?: string[];
cursor?: PaginationCursor;
};
@@ -0,0 +1,76 @@
/**
* Contains a public-private key pair and the associated key ID.
*/
export type KeyMaterial = {
keyId: string,
keyPair: { publicJwk: PublicJwk, privateJwk: PrivateJwk }
};
export type Jwk = {
/** The "alg" (algorithm) parameter identifies the algorithm intended for use with the key. */
alg?: string;
/** The "alg" (algorithm) parameter identifies the algorithm intended for use with the key. */
kid?: string;
/** identifies the cryptographic algorithm family used with the key, such "EC". */
kty: string;
};
export type PublicJwk = Jwk & {
/** The "crv" (curve) parameter identifies the cryptographic curve used with the key.
* MUST be present for all EC public keys
*/
crv: 'Ed25519' | 'secp256k1' | 'P-256';
/**
* the x coordinate for the Elliptic Curve point.
* Represented as the base64url encoding of the octet string representation of the coordinate.
* MUST be present for all EC public keys
*/
x: string;
/**
* the y coordinate for the Elliptic Curve point.
* Represented as the base64url encoding of the octet string representation of the coordinate.
*/
y?: string;
};
export type PrivateJwk = PublicJwk & {
/**
* the Elliptic Curve private key value.
* It is represented as the base64url encoding of the octet string representation of the private key value
* MUST be present to represent Elliptic Curve private keys.
*/
d: string;
};
export interface SignatureAlgorithm {
/**
* signs the provided payload using the provided JWK
* @param content - the content to sign
* @param privateJwk - the key to sign with
* @returns the signed content (aka signature)
*/
sign(content: Uint8Array, privateJwk: PrivateJwk): Promise<Uint8Array>;
/**
* Verifies a signature against the provided payload hash and public key.
* @param content - the content to verify with
* @param signature - the signature to verify against
* @param publicJwk - the key to verify with
* @returns a boolean indicating whether the signature matches
*/
verify(content: Uint8Array, signature: Uint8Array, publicJwk: PublicJwk): Promise<boolean>;
/**
* generates a random key pair
* @returns the public and private keys as JWKs
*/
generateKeyPair(): Promise<{ publicJwk: PublicJwk, privateJwk: PrivateJwk }>
/**
* converts public key in bytes into a JWK
* @param publicKeyBytes - the public key to convert into JWK
* @returns the public key in JWK format
*/
publicKeyToJwk(publicKeyBytes: Uint8Array): Promise<PublicJwk>
}
@@ -0,0 +1,28 @@
/**
* General JWS definition. Payload is returned as an empty
* string when JWS Unencoded Payload Option
* [RFC7797](https://www.rfc-editor.org/rfc/rfc7797) is used.
*/
export type GeneralJws = {
payload: string
signatures: SignatureEntry[]
};
/**
* An entry of the `signatures` array in a general JWS.
*/
export type SignatureEntry = {
/**
* The "protected" member MUST be present and contain the value
* BASE64URL(UTF8(JWS Protected Header)) when the JWS Protected
* Header value is non-empty; otherwise, it MUST be absent. These
* Header Parameter values are integrity protected.
*/
protected: string
/**
* The "signature" member MUST be present and contain the value
* BASE64URL(JWS Signature).
*/
signature: string
};
@@ -0,0 +1,30 @@
import type { GenericMessage, GenericSignaturePayload } from './message-types.js';
/**
* An generic interface that represents a DWN message and convenience methods for working with it.
*/
export interface MessageInterface<M extends GenericMessage> {
/**
* Valid JSON message representing this DWN message.
*/
get message(): M;
/**
* Gets the signer of this message.
* This is not to be confused with the logical author of the message.
*/
get signer(): string | undefined;
/**
* DID of the logical author of this message.
* NOTE: we say "logical" author because a message can be signed by a delegate of the actual author,
* in which case the author DID would not be the same as the signer/delegate DID,
* but be the DID of the grantor (`grantedBy`) of the delegated grant presented.
*/
get author(): string | undefined;
/**
* Decoded payload of the signature of this message.
*/
get signaturePayload(): GenericSignaturePayload | undefined;
}
@@ -0,0 +1,57 @@
import type { Filter, KeyValues, PaginationCursor } from './query-types.js';
import type { GenericMessage, MessageSort, Pagination } from './message-types.js';
export interface MessageStoreOptions {
signal?: AbortSignal;
}
export interface MessageStore {
/**
* opens a connection to the underlying store
*/
open(): Promise<void>;
/**
* closes the connection to the underlying store
*/
close(): Promise<void>;
/**
* adds a message to the underlying store. Uses the message's cid as the key
* @param indexes indexes (key-value pairs) to be included as part of this put operation
*/
put(
tenant: string,
message: GenericMessage,
indexes: KeyValues,
options?: MessageStoreOptions
): Promise<void>;
/**
* Fetches a single message by `cid` from the underlying store.
* Returns `undefined` no message was found.
*/
get(tenant: string, cid: string, options?: MessageStoreOptions): Promise<GenericMessage | undefined>;
/**
* Queries the underlying store for messages that matches the provided filters.
* Supplying multiple filters establishes an OR condition between the filters.
*/
query(
tenant: string,
filters: Filter[],
messageSort?: MessageSort,
pagination?: Pagination,
options?: MessageStoreOptions
): Promise<{ messages: GenericMessage[], cursor?: PaginationCursor}>;
/**
* Deletes the message associated with the id provided.
*/
delete(tenant: string, cid: string, options?: MessageStoreOptions): Promise<void>;
/**
* Clears the entire store. Mainly used for cleaning up in test environment.
*/
clear(): Promise<void>;
}
@@ -0,0 +1,131 @@
import type { GeneralJws } from './jws-types.js';
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import type { PaginationCursor, SortDirection } from './query-types.js';
/**
* Intersection type for all concrete message types.
*/
export type GenericMessage = {
descriptor: Descriptor;
authorization?: AuthorizationModel;
};
/**
* The data model for the `authorization` property in a DWN message.
*/
export type AuthorizationModel = {
/**
* The signature of the message signer.
* NOTE: the signer is not necessarily the logical author of the message (e.g. signer is a delegate).
*/
signature: GeneralJws;
/**
* The delegated grant required when the message is signed by an author-delegate.
*/
authorDelegatedGrant?: DelegatedGrantRecordsWriteMessage;
/**
* An "overriding" signature for a DWN owner or owner-delegate to store a message authored by another entity.
*/
ownerSignature?: GeneralJws;
/**
* The delegated grant required when the message is signed by an owner-delegate.
*/
ownerDelegatedGrant?: DelegatedGrantRecordsWriteMessage;
};
type DelegatedGrantRecordsWriteMessage = {
authorization: {
/**
* The signature of the author.
*/
signature: GeneralJws;
},
recordId: string,
contextId?: string;
// NOTE: This is a direct copy of `RecordsWriteDescriptor` to avoid circular references.
descriptor: {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Write;
protocol?: string;
protocolPath?: string;
recipient?: string;
schema?: string;
parentId?: string;
dataCid: string;
dataSize: number;
dateCreated: string;
messageTimestamp: string;
published?: boolean;
datePublished?: string;
dataFormat: string;
};
};
/**
* Type of common decoded `authorization` property payload.
*/
export type GenericSignaturePayload = {
descriptorCid: string;
permissionGrantId?: string;
/**
* Record ID of a permission grant DWN `RecordsWrite` with `delegated` set to `true`.
*/
delegatedGrantId?: string;
/**
* Used in the Records interface to authorize role-authorized actions for protocol records.
*/
protocolRole?: string;
};
/**
* Intersection type for all DWN message descriptor.
*/
export type Descriptor = {
interface: string;
method: string;
messageTimestamp: string;
};
/**
* Message returned in a query result.
* NOTE: the message structure is a modified version of the message received, the most notable differences are:
* 1. May include encoded data
*/
export type QueryResultEntry = GenericMessage & {
encodedData?: string;
};
export interface MessageSubscription {
id: string;
close: () => Promise<void>;
};
/**
* Pagination Options for querying messages.
*
* The cursor is the messageCid of the message you would like to pagination from.
*/
export type Pagination = {
cursor?: PaginationCursor;
limit?: number;
};
type Status = {
code: number
detail: string
};
export type GenericMessageReply = {
status: Status;
};
export type MessageSort = {
dateCreated?: SortDirection;
datePublished?: SortDirection;
messageTimestamp?: SortDirection;
};
@@ -0,0 +1,25 @@
import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js';
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type MessagesGetDescriptor = {
interface : DwnInterfaceName.Messages;
method: DwnMethodName.Get;
messageCids: string[];
messageTimestamp: string;
};
export type MessagesGetMessage = GenericMessage & {
authorization: AuthorizationModel; // overriding `GenericMessage` with `authorization` being required
descriptor: MessagesGetDescriptor;
};
export type MessagesGetReplyEntry = {
messageCid: string;
message?: GenericMessage;
encodedData?: string;
error?: string;
};
export type MessagesGetReply = GenericMessageReply & {
entries?: MessagesGetReplyEntry[];
};
@@ -0,0 +1,19 @@
import type { MessageSubscriptionHandler } from './events-types.js';
import type { Readable } from 'readable-stream';
import type { RecordSubscriptionHandler } from './records-types.js';
import type { GenericMessage, GenericMessageReply } from './message-types.js';
/**
* Interface that defines a message handler of a specific method.
*/
export interface MethodHandler {
/**
* Handles the given message and returns a `MessageReply` response.
*/
handle(input: {
tenant: string;
message: GenericMessage;
dataStream?: Readable
subscriptionHandler?: MessageSubscriptionHandler | RecordSubscriptionHandler;
}): Promise<GenericMessageReply>;
}
@@ -0,0 +1,104 @@
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
/**
* Type for the data payload of a permission request message.
*/
export type PermissionRequestData = {
/**
* If the grant is a delegated grant or not. If `true`, the `grantedTo` will be able to act as the `grantedBy` within the scope of this grant.
*/
delegated: boolean;
/**
* Optional string that communicates what the grant would be used for.
*/
description?: string;
/**
* The scope of the allowed access.
*/
scope: PermissionScope;
conditions?: PermissionConditions
};
/**
* Type for the data payload of a permission grant message.
*/
export type PermissionGrantData = {
/**
* Optional string that communicates what the grant would be used for
*/
description?: string;
/**
* Optional CID of a permission request. This is optional because grants may be given without being officially requested
* */
requestId?: string;
/**
* Timestamp at which this grant will no longer be active.
*/
dateExpires: string;
/**
* Whether this grant is delegated or not. If `true`, the `grantedTo` will be able to act as the `grantedTo` within the scope of this grant.
*/
delegated?: boolean;
/**
* The scope of the allowed access.
*/
scope: PermissionScope;
conditions?: PermissionConditions
};
/**
* Type for the data payload of a permission revocation message.
*/
export type PermissionRevocationData = {
/**
* Optional string that communicates the details of the revocation.
*/
description?: string;
};
/**
* The data model for a permission scope.
*/
export type PermissionScope = {
interface: DwnInterfaceName;
method: DwnMethodName;
} | RecordsPermissionScope;
/**
* The data model for a permission scope that is specific to the Records interface.
*/
export type RecordsPermissionScope = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Read | DwnMethodName.Write | DwnMethodName.Query | DwnMethodName.Subscribe | DwnMethodName.Delete;
/** May only be present when `schema` is undefined */
protocol?: string;
/** May only be present when `protocol` is defined and `protocolPath` is undefined */
contextId?: string;
/** May only be present when `protocol` is defined and `contextId` is undefined */
protocolPath?: string;
/** May only be present when `protocol` is undefined */
schema?: string;
};
export enum PermissionConditionPublication {
Required = 'Required',
Prohibited = 'Prohibited',
}
export type PermissionConditions = {
/**
* indicates whether a message written with the invocation of a permission must, may, or must not
* be marked as public.
* If `undefined`, it is optional to make the message public.
*/
publication?: PermissionConditionPublication;
};
@@ -0,0 +1,177 @@
import type { PublicJwk } from './jose-types.js';
import type { AuthorizationModel, GenericMessage, GenericMessageReply } from './message-types.js';
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
export type ProtocolsConfigureDescriptor = {
interface : DwnInterfaceName.Protocols;
method: DwnMethodName.Configure;
messageTimestamp: string;
definition: ProtocolDefinition;
};
export type ProtocolDefinition = {
protocol: string;
/**
* Denotes if this Protocol Definition can be returned by unauthenticated or unauthorized `ProtocolsQuery`.
*/
published: boolean;
types: ProtocolTypes;
structure: {
[key: string]: ProtocolRuleSet;
}
};
export type ProtocolType = {
schema?: string,
dataFormats?: string[],
};
export type ProtocolTypes = {
[key: string]: ProtocolType;
};
export enum ProtocolActor {
Anyone = 'anyone',
Author = 'author',
Recipient = 'recipient'
}
export enum ProtocolAction {
CoDelete = 'co-delete',
CoPrune = 'co-prune',
CoUpdate = 'co-update',
Create = 'create',
Delete = 'delete',
Prune = 'prune',
Query = 'query',
Read = 'read',
Subscribe = 'subscribe',
Update = 'update'
}
/**
* Rules defining which actors may access a record at the given protocol path.
* Rules take three forms, e.g.:
* 1. Anyone can create.
* {
* who: 'anyone',
* can: ['create']
* }
*
* 2. Author of protocolPath can create; OR
* Recipient of protocolPath can write.
* {
* who: 'recipient'
* of: 'requestForQuote',
* can: ['create']
* }
*
* 3. Role can create.
* {
* role: 'friend',
* can: ['create']
* }
*/
export type ProtocolActionRule = {
/**
* May be 'anyone' | 'author' | 'recipient'.
* If `who` === 'anyone', then `of` must be omitted. Otherwise `of` must be present.
* Mutually exclusive with `role`
*/
who?: string,
/**
* The protocol path of a role record type marked with $role: true.
* Mutually exclusive with `who`
*/
role?: string;
/**
* Protocol path.
* Must be present if `who` === 'author' or 'recipient'
*/
of?: string;
/**
* Array of actions that the actor/role can perform.
* See {ProtocolAction} for possible values.
* 'query' and 'subscribe' are only supported for `role` rules.
*/
can: string[];
};
/**
* Config for protocol-path encryption scheme.
*/
export type ProtocolPathEncryption = {
/**
* The ID of the root key that derives the public key at this protocol path for encrypting the symmetric key used for data encryption.
*/
rootKeyId: string;
/**
* Public key for encrypting the symmetric key used for data encryption.
*/
publicKeyJwk: PublicJwk;
};
export type ProtocolRuleSet = {
/**
* Encryption setting for objects that are in this protocol path.
*/
$encryption?: ProtocolPathEncryption;
$actions?: ProtocolActionRule[];
/**
* If true, this marks a record as a `role` that may used within a context.
* The recipient of a $role record may invoke their role by setting `protocolRole` property to the protocol path of the $role record.
*/
$role?: boolean;
/**
* If $size is set, the record size in bytes must be within the limits.
*/
$size?: {
min?: number,
max?: number
}
/**
* If $tags is set, the record must conform to the tag rules.
*/
$tags?: {
/** array of required tags */
$requiredTags?: string[],
/** allow properties other than those explicitly listed. defaults to false */
$allowUndefinedTags?: boolean;
[key: string]: any;
}
// JSON Schema verifies that properties other than properties prefixed with $ will actually have type ProtocolRuleSet
[key: string]: any;
};
export type ProtocolsConfigureMessage = GenericMessage & {
authorization: AuthorizationModel; // overriding `GenericMessage` with `authorization` being required
descriptor: ProtocolsConfigureDescriptor;
};
export type ProtocolsQueryFilter = {
protocol: string,
};
export type ProtocolsQueryDescriptor = {
interface : DwnInterfaceName.Protocols,
method: DwnMethodName.Query;
messageTimestamp: string;
filter?: ProtocolsQueryFilter
};
export type ProtocolsQueryMessage = GenericMessage & {
descriptor: ProtocolsQueryDescriptor;
};
export type ProtocolsQueryReply = GenericMessageReply & {
entries?: ProtocolsConfigureMessage[];
};
@@ -0,0 +1,61 @@
export type QueryOptions = {
sortProperty: string;
sortDirection?: SortDirection;
limit?: number;
cursor?: PaginationCursor;
};
export enum SortDirection {
Descending = -1,
Ascending = 1
}
export type KeyValues = { [key:string]: string | number | boolean | string[] | number[] };
export type EqualFilter = string | number | boolean;
export type OneOfFilter = EqualFilter[];
export type RangeValue = string | number;
/**
* "greater than" or "greater than or equal to" range condition. `gt` and `gte` are mutually exclusive.
*/
export type GT = ({ gt: RangeValue } & { gte?: never }) | ({ gt?: never } & { gte: RangeValue });
/**
* "less than" or "less than or equal to" range condition. `lt`, `lte` are mutually exclusive.
*/
export type LT = ({ lt: RangeValue } & { lte?: never }) | ({ lt?: never } & { lte: RangeValue });
/**
* Ranger filter. 1 condition is required.
*/
export type RangeFilter = (GT | LT) & Partial<GT> & Partial<LT>;
export type StartsWithFilter = {
startsWith: string;
};
export type FilterValue = EqualFilter | OneOfFilter | RangeFilter;
export type Filter = {
[property: string]: FilterValue;
};
export type RangeCriterion = {
/**
* Inclusive starting date-time.
*/
from?: string;
/**
* Inclusive end date-time.
*/
to?: string;
};
export type PaginationCursor = {
messageCid: string;
value: string | number;
};
@@ -0,0 +1,237 @@
import type { EncryptionAlgorithm } from '../utils/encryption.js';
import type { GeneralJws } from './jws-types.js';
import type { KeyDerivationScheme } from '../utils/hd-key.js';
import type { PublicJwk } from './jose-types.js';
import type { Readable } from 'readable-stream';
import type { AuthorizationModel, GenericMessage, GenericMessageReply, GenericSignaturePayload, MessageSubscription, Pagination } from './message-types.js';
import type { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import type { PaginationCursor, RangeCriterion, RangeFilter, StartsWithFilter } from './query-types.js';
export enum DateSort {
CreatedAscending = 'createdAscending',
CreatedDescending = 'createdDescending',
PublishedAscending = 'publishedAscending',
PublishedDescending = 'publishedDescending'
}
export type RecordsWriteTagValue = string | number | boolean | string[] | number[];
export type RecordsWriteTags = {
[property: string]: RecordsWriteTagValue;
};
export type RecordsWriteTagsFilter = StartsWithFilter | RangeFilter | string | number | boolean;
export type RecordsWriteDescriptor = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Write;
protocol?: string;
protocolPath?: string;
recipient?: string;
schema?: string;
tags?: RecordsWriteTags;
parentId?: string;
dataCid: string;
dataSize: number;
dateCreated: string;
messageTimestamp: string;
published?: boolean;
datePublished?: string;
dataFormat: string;
};
export type RecordsWriteMessageOptions = {
dataStream?: Readable;
};
/**
* Internal RecordsWrite message representation that can be in an incomplete state.
*/
export type InternalRecordsWriteMessage = GenericMessage & {
recordId?: string,
contextId?: string;
descriptor: RecordsWriteDescriptor;
attestation?: GeneralJws;
encryption?: EncryptionProperty;
};
export type RecordsWriteMessage = {
authorization: AuthorizationModel; // overriding `GenericMessage` with `authorization` being required
recordId: string,
contextId?: string;
descriptor: RecordsWriteDescriptor;
attestation?: GeneralJws;
encryption?: EncryptionProperty;
};
export type EncryptionProperty = {
algorithm: EncryptionAlgorithm;
initializationVector: string;
keyEncryption: EncryptedKey[]
};
export type EncryptedKey = {
/**
* The fully qualified key ID (e.g. did:example:abc#encryption-key-id) of the root public key used to encrypt the symmetric encryption key.
*/
rootKeyId: string;
/**
* The actual derived public key.
*/
derivedPublicKey?: PublicJwk;
derivationScheme: KeyDerivationScheme;
algorithm: EncryptionAlgorithm;
initializationVector: string;
ephemeralPublicKey: PublicJwk;
messageAuthenticationCode: string;
encryptedKey: string;
};
/**
* Data structure returned in a `RecordsQuery` reply entry.
* NOTE: the message structure is a modified version of the message received, the most notable differences are:
* 1. May include an initial RecordsWrite message
* 2. May include encoded data
*/
export type RecordsQueryReplyEntry = RecordsWriteMessage & {
/**
* The initial write of the record if the returned RecordsWrite message itself is not the initial write.
*/
initialWrite?: RecordsWriteMessage;
/**
* The encoded data of the record if the data associated with the record is equal or smaller than `DwnConstant.maxDataSizeAllowedToBeEncoded`.
*/
encodedData?: string;
};
/**
* Represents a RecordsWrite message with encoded data attached.
*/
export type DataEncodedRecordsWriteMessage = RecordsWriteMessage & {
/**
* The encoded data of the record if the data associated with the record is equal or smaller than `DwnConstant.maxDataSizeAllowedToBeEncoded`.
*/
encodedData?: string;
};
export type RecordsQueryDescriptor = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Query;
messageTimestamp: string;
filter: RecordsFilter;
dateSort?: DateSort;
pagination?: Pagination;
};
export type RecordsSubscribeDescriptor = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Subscribe;
messageTimestamp: string;
filter: RecordsFilter;
};
export type RecordsFilter = {
/**
* The logical author of the record
*/
author?: string;
attester?: string;
recipient?: string;
protocol?: string;
protocolPath?: string;
published?: boolean;
/**
* When given all Records message under the context of the given `contextId` will be returned.
*/
contextId?: string;
schema?: string;
tags?: { [property:string]: RecordsWriteTagsFilter }
recordId?: string;
parentId?: string;
dataFormat?: string;
dataSize?: RangeFilter;
dataCid?: string;
dateCreated?: RangeCriterion;
datePublished?: RangeCriterion;
dateUpdated?: RangeCriterion;
};
export type RecordsWriteAttestationPayload = {
descriptorCid: string;
};
export type RecordsWriteSignaturePayload = GenericSignaturePayload & {
recordId: string;
contextId?: string;
attestationCid?: string;
encryptionCid?: string;
};
export type RecordsQueryMessage = GenericMessage & {
descriptor: RecordsQueryDescriptor;
};
export type RecordsQueryReply = GenericMessageReply & {
entries?: RecordsQueryReplyEntry[];
cursor?: PaginationCursor;
};
export type RecordEvent = {
message: RecordsWriteMessage | RecordsDeleteMessage
initialWrite?: RecordsWriteMessage;
};
export type RecordSubscriptionHandler = (event: RecordEvent) => void;
export type RecordsSubscribeMessageOptions = {
subscriptionHandler: RecordSubscriptionHandler;
};
export type RecordsSubscribeMessage = GenericMessage & {
descriptor: RecordsSubscribeDescriptor;
};
export type RecordsSubscribeReply = GenericMessageReply & {
subscription?: MessageSubscription;
};
export type RecordsReadMessage = {
authorization?: AuthorizationModel;
descriptor: RecordsReadDescriptor;
};
export type RecordsReadReply = GenericMessageReply & {
record?: RecordsWriteMessage & {
/**
* The initial write of the record if the returned RecordsWrite message itself is not the initial write.
*/
initialWrite?: RecordsWriteMessage;
data: Readable;
};
};
export type RecordsReadDescriptor = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Read;
filter: RecordsFilter;
messageTimestamp: string;
};
export type RecordsDeleteMessage = GenericMessage & {
authorization: AuthorizationModel; // overriding `GenericMessage` with `authorization` being required
descriptor: RecordsDeleteDescriptor;
};
export type RecordsDeleteDescriptor = {
interface: DwnInterfaceName.Records;
method: DwnMethodName.Delete;
messageTimestamp: string;
recordId: string;
/**
* Denotes if all the descendent records should be purged.
*/
prune: boolean
};
@@ -0,0 +1,27 @@
/**
* A signer that is capable of generating a digital signature over any given bytes.
*/
export interface Signer {
/**
* The ID of the key used by this signer.
* This needs to be a fully-qualified ID (ie. prefixed with DID) so that author can be parsed out for processing such as `recordId` computation.
* Example: did:example:alice#key1
* This value will be used as the "kid" parameter in JWS produced.
* While this property is not a required property per JWS specification, it is required for DWN authentication.
*/
keyId: string
/**
* The name of the signature algorithm used by this signer.
* This value will be used as the "alg" parameter in JWS produced.
* This parameter is not used by the DWN but is unfortunately a required header property for a JWS as per:
* https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.1
* Valid signature algorithm values can be found at https://www.iana.org/assignments/jose/jose.xhtml
*/
algorithm: string;
/**
* Signs the given content and returns the signature as bytes.
*/
sign (content: Uint8Array): Promise<Uint8Array>;
}
@@ -0,0 +1,34 @@
import type { GenericMessageReply } from '../types/message-types.js';
import type { KeyValues } from './query-types.js';
import type { RecordsWriteMessage } from './records-types.js';
import type { GenericMessage, MessageSubscription } from './message-types.js';
export type EventListener = (tenant: string, event: MessageEvent, indexes: KeyValues) => void;
/**
* MessageEvent contains the message being emitted and an optional initial write message.
*/
export type MessageEvent = {
message: GenericMessage;
/** the initial write of the RecordsWrite or RecordsDelete message */
initialWrite?: RecordsWriteMessage
};
/**
* The EventStream interface implements a pub/sub system based on Message filters.
*/
export interface EventStream {
subscribe(tenant: string, id: string, listener: EventListener): Promise<EventSubscription>;
emit(tenant: string, event: MessageEvent, indexes: KeyValues): void;
open(): Promise<void>;
close(): Promise<void>;
}
export interface EventSubscription {
id: string;
close: () => Promise<void>;
}
export type SubscriptionReply = GenericMessageReply & {
subscription?: MessageSubscription;
};
@@ -0,0 +1,31 @@
/**
* Wraps the given `AbortSignal` in a `Promise` that rejects if it is programmatically triggered,
* otherwise the promise will remain in await state (will never resolve).
*/
function promisifySignal<T>(signal: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
// immediately reject if the given is signal is already aborted
if (signal.aborted) {
reject(signal.reason);
return;
}
signal.addEventListener('abort', () => {
reject(signal.reason);
});
});
}
/**
* Wraps the given `Promise` such that it will reject if the `AbortSignal` is triggered.
*/
export async function executeUnlessAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) {
return promise;
}
return Promise.race([
promise,
promisifySignal<T>(signal),
]);
}
@@ -0,0 +1,39 @@
/**
* Array utility methods.
*/
export class ArrayUtility {
/**
* Returns `true` if content of the two given byte arrays are equal; `false` otherwise.
*/
public static byteArraysEqual(array1: Uint8Array, array2:Uint8Array): boolean {
const equal = array1.length === array2.length && array1.every((value, index) => value === array2[index]);
return equal;
}
/**
* Asynchronously iterates an {AsyncGenerator} to return all the values in an array.
*/
public static async fromAsyncGenerator<T>(iterator: AsyncGenerator<T>): Promise<Array<T>> {
const array: Array<T> = [ ];
for await (const value of iterator) {
array.push(value);
}
return array;
}
/**
* Generic asynchronous sort method.
*/
public static async asyncSort<T>(array: T[], asyncComparer: (a: T, b: T) => Promise<number>): Promise<T[]> {
// this is a bubble sort implementation
for (let i = 0; i < array.length; i++) {
for (let j = i + 1; j < array.length; j++) {
const comparison = await asyncComparer(array[i], array[j]);
if (comparison > 0) {
[array[i], array[j]] = [array[j], array[i]]; // Swap
}
}
}
return array;
}
}
+101
View File
@@ -0,0 +1,101 @@
import * as cbor from '@ipld/dag-cbor';
import type { Readable } from 'readable-stream';
import { BlockstoreMock } from '../store/blockstore-mock.js';
import { CID } from 'multiformats/cid';
import { importer } from 'ipfs-unixfs-importer';
import { sha256 } from 'multiformats/hashes/sha2';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
// a map of all supported CID hashing algorithms. This map is used to select the appropriate hasher
// when generating a CID to compare against a provided CID
const hashers = {
[sha256.code as number]: sha256,
};
// a map of all support codecs.This map is used to select the appropriate codec
// when generating a CID to compare against a provided CID
const codecs = {
[cbor.code as number]: cbor
};
/**
* Utility class for creating CIDs. Exported for the convenience of developers.
*/
export class Cid {
/**
* Computes a V1 CID for the provided payload
* @param codecCode - the codec to use. Defaults to cbor
* @param multihashCode - the multihasher to use. Defaults to sha256
* @returns payload CID
* @throws {Error} codec is not supported
* @throws {Error} encoding fails
* @throws {Error} if hasher is not supported
*/
public static async computeCid(
payload: any,
codecCode: number = cbor.code,
multihashCode: number = sha256.code
): Promise<string> {
const codec = codecs[codecCode];
if (!codec) {
throw new DwnError(DwnErrorCode.ComputeCidCodecNotSupported, `codec [${codecCode}] not supported`);
}
const hasher = hashers[multihashCode];
if (!hasher) {
throw new DwnError(DwnErrorCode.ComputeCidMultihashNotSupported, `multihash code [${multihashCode}] not supported`);
}
const payloadBytes = codec.encode(payload);
const payloadHash = await hasher.digest(payloadBytes);
const cid = await CID.createV1(codec.code, payloadHash);
return cid.toString();
}
/**
* Parses the given CID string into a {CID}.
*/
public static parseCid(str: string): CID {
const cid: CID = CID.parse(str).toV1();
if (!codecs[cid.code]) {
throw new DwnError(DwnErrorCode.ParseCidCodecNotSupported, `codec [${cid.code}] not supported`);
}
if (!hashers[cid.multihash.code]) {
throw new DwnError(DwnErrorCode.ParseCidMultihashNotSupported, `multihash code [${cid.multihash.code}] not supported`);
}
return cid;
}
/**
* @returns V1 CID of the DAG comprised by chunking data into unixfs DAG-PB encoded blocks
*/
public static async computeDagPbCidFromBytes(content: Uint8Array): Promise<string> {
const asyncDataBlocks = importer([{ content }], new BlockstoreMock(), { cidVersion: 1 });
// NOTE: the last block contains the root CID
let block;
for await (block of asyncDataBlocks) { ; }
return block ? block.cid.toString() : '';
}
/**
* @returns V1 CID of the DAG comprised by chunking data into unixfs DAG-PB encoded blocks
*/
public static async computeDagPbCidFromStream(dataStream: Readable): Promise<string> {
const asyncDataBlocks = importer([{ content: dataStream }], new BlockstoreMock(), { cidVersion: 1 });
// NOTE: the last block contains the root CID
let block;
for await (block of asyncDataBlocks) { ; }
return block ? block.cid.toString() : '';
}
}
@@ -0,0 +1,99 @@
import { Encoder } from './encoder.js';
import { PassThrough, Readable } from 'readable-stream';
/**
* Utility class for readable data stream, intentionally named to disambiguate from ReadableStream, readable-stream, Readable etc.
*/
export class DataStream {
/**
* Reads the entire readable stream given into array of bytes.
*/
public static async toBytes(readableStream: Readable): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const chunks: any[] = [];
readableStream.on('data', chunk => {
chunks.push(chunk);
});
readableStream.on('end', () => {
const uint8Array = DataStream.concatenateArrayOfBytes(chunks);
resolve(uint8Array);
});
readableStream.on('error', reject);
});
}
/**
* Reads the entire readable stream and JSON parses it into an object.
*/
public static async toObject(readableStream: Readable): Promise<object> {
const contentBytes = await DataStream.toBytes(readableStream);
const contentObject = Encoder.bytesToObject(contentBytes);
return contentObject;
}
/**
* Concatenates the array of bytes given into one Uint8Array.
*/
private static concatenateArrayOfBytes(arrayOfBytes: Uint8Array[]): Uint8Array {
// sum of individual array lengths
const totalLength = arrayOfBytes.reduce((accumulatedValue, currentValue) => accumulatedValue + currentValue.length, 0);
const result = new Uint8Array(totalLength);
let length = 0;
for (const bytes of arrayOfBytes) {
result.set(bytes, length);
length += bytes.length;
}
return result;
}
/**
* Creates a readable stream from the bytes given.
*/
public static fromBytes(bytes: Uint8Array): Readable {
// chunk up the bytes to simulate a more real-world like behavior
const chunkLength = 100_000;
let currentIndex = 0;
const readableStream = new Readable({
read(_size): void {
// if this is the last chunk
if (currentIndex + chunkLength > bytes.length) {
this.push(bytes.subarray(currentIndex));
this.push(null);
} else {
this.push(bytes.subarray(currentIndex, currentIndex + chunkLength));
currentIndex = currentIndex + chunkLength;
}
}
});
return readableStream;
}
/**
* Creates a readable stream from the object given.
*/
public static fromObject(object: Record<string, any>): Readable {
const bytes = Encoder.objectToBytes(object);
return DataStream.fromBytes(bytes);
}
/**
* Duplicates the given data stream into the number of streams specified so that multiple handlers can consume the same data stream.
*/
public static duplicateDataStream(dataStream: Readable, count: number): Readable[] {
const streams: Readable[] = [];
for (let i = 0; i < count; i++) {
const passThrough = new PassThrough();
dataStream.pipe(passThrough);
streams.push(passThrough as unknown as Readable);
}
return streams;
}
}
@@ -0,0 +1,54 @@
import { base64url } from 'multiformats/bases/base64';
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
/**
* Utility class for encoding/converting data into various formats.
*/
export class Encoder {
public static base64UrlToBytes(base64urlString: string): Uint8Array {
const content = base64url.baseDecode(base64urlString);
return content;
}
public static base64UrlToObject(base64urlString: string): any {
const payloadBytes = base64url.baseDecode(base64urlString);
const payloadObject = Encoder.bytesToObject(payloadBytes);
return payloadObject;
}
public static bytesToBase64Url(bytes: Uint8Array): string {
const base64UrlString = base64url.baseEncode(bytes);
return base64UrlString;
}
public static bytesToString(content: Uint8Array): string {
const bytes = textDecoder.decode(content);
return bytes;
}
public static bytesToObject(content: Uint8Array): object {
const contentString = Encoder.bytesToString(content);
const contentObject = JSON.parse(contentString);
return contentObject;
}
public static objectToBytes(obj: Record<string, any>): Uint8Array {
const objectString = JSON.stringify(obj);
const objectBytes = textEncoder.encode(objectString);
return objectBytes;
}
public static stringToBase64Url(content: string): string {
const bytes = textEncoder.encode(content);
const base64UrlString = base64url.baseEncode(bytes);
return base64UrlString;
}
public static stringToBytes(content: string): Uint8Array {
const bytes = textEncoder.encode(content);
return bytes;
}
}
@@ -0,0 +1,145 @@
import * as crypto from 'crypto';
import * as eciesjs from 'eciesjs';
import { Readable } from 'readable-stream';
// compress publicKey for message encryption
eciesjs.ECIES_CONFIG.isEphemeralKeyCompressed = true;
/**
* Utility class for performing common, non-DWN specific encryption operations.
*/
export class Encryption {
/**
* Encrypts the given plaintext stream using AES-256-CTR algorithm.
*/
public static async aes256CtrEncrypt(key: Uint8Array, initializationVector: Uint8Array, plaintextStream: Readable): Promise<Readable> {
const cipher = crypto.createCipheriv('aes-256-ctr', key, initializationVector);
const cipherStream = new Readable({
read(): void { }
});
plaintextStream.on('data', (chunk) => {
const encryptedChunk = cipher.update(chunk);
cipherStream.push(encryptedChunk);
});
plaintextStream.on('end', () => {
const finalChunk = cipher.final();
cipherStream.push(finalChunk);
cipherStream.push(null);
});
plaintextStream.on('error', (err) => {
cipherStream.emit('error', err);
});
return cipherStream;
}
/**
* Decrypts the given cipher stream using AES-256-CTR algorithm.
*/
public static async aes256CtrDecrypt(key: Uint8Array, initializationVector: Uint8Array, cipherStream: Readable): Promise<Readable> {
const decipher = crypto.createDecipheriv('aes-256-ctr', key, initializationVector);
const plaintextStream = new Readable({
read(): void { }
});
cipherStream.on('data', (chunk) => {
const decryptedChunk = decipher.update(chunk);
plaintextStream.push(decryptedChunk);
});
cipherStream.on('end', () => {
const finalChunk = decipher.final();
plaintextStream.push(finalChunk);
plaintextStream.push(null);
});
cipherStream.on('error', (err) => {
plaintextStream.emit('error', err);
});
return plaintextStream;
}
/**
* Encrypts the given plaintext using ECIES (Elliptic Curve Integrated Encryption Scheme)
* with SECP256K1 for the asymmetric calculations, HKDF as the key-derivation function,
* and AES-GCM for the symmetric encryption and MAC algorithms.
*/
public static async eciesSecp256k1Encrypt(publicKeyBytes: Uint8Array, plaintext: Uint8Array): Promise<EciesEncryptionOutput> {
// underlying library requires Buffer as input
const publicKey = Buffer.from(publicKeyBytes);
const plaintextBuffer = Buffer.from(plaintext);
const cryptogram = eciesjs.encrypt(publicKey, plaintextBuffer);
// split cryptogram returned into constituent parts
let start = 0;
let end = Encryption.isEphemeralKeyCompressed ? 33 : 65;
const ephemeralPublicKey = cryptogram.subarray(start, end);
start = end;
end += eciesjs.ECIES_CONFIG.symmetricNonceLength;
const initializationVector = cryptogram.subarray(start, end);
start = end;
end += 16; // eciesjs.consts.AEAD_TAG_LENGTH
const messageAuthenticationCode = cryptogram.subarray(start, end);
const ciphertext = cryptogram.subarray(end);
return {
ciphertext,
ephemeralPublicKey,
initializationVector,
messageAuthenticationCode
};
}
/**
* Decrypt the given plaintext using ECIES (Elliptic Curve Integrated Encryption Scheme)
* with SECP256K1 for the asymmetric calculations, HKDF as the key-derivation function,
* and AES-GCM for the symmetric encryption and MAC algorithms.
*/
public static async eciesSecp256k1Decrypt(input: EciesEncryptionInput): Promise<Uint8Array> {
// underlying library requires Buffer as input
const privateKeyBuffer = Buffer.from(input.privateKey);
const eciesEncryptionOutput = Buffer.concat([
input.ephemeralPublicKey,
input.initializationVector,
input.messageAuthenticationCode,
input.ciphertext
]);
const plaintext = eciesjs.decrypt(privateKeyBuffer, eciesEncryptionOutput);
return plaintext;
}
/**
* Expose eciesjs library configuration
*/
static get isEphemeralKeyCompressed():boolean {
return eciesjs.ECIES_CONFIG.isEphemeralKeyCompressed;
}
}
export type EciesEncryptionOutput = {
initializationVector: Uint8Array;
ephemeralPublicKey: Uint8Array;
ciphertext: Uint8Array;
messageAuthenticationCode: Uint8Array;
};
export type EciesEncryptionInput = EciesEncryptionOutput & {
privateKey: Uint8Array;
};
export enum EncryptionAlgorithm {
Aes256Ctr = 'A256CTR',
EciesSecp256k1 = 'ECIES-ES256K'
}
@@ -0,0 +1,95 @@
import type { Filter } from '../types/query-types.js';
import type { EventsFilter, EventsMessageFilter, EventsRecordsFilter } from '../types/events-types.js';
import { FilterUtility } from '../utils/filter.js';
import { Records } from '../utils/records.js';
import { isEmptyObject, removeUndefinedProperties } from './object.js';
/**
* Class containing Events related utility methods.
*/
export class Events {
/**
* Normalizes/fixes the formatting of the given filters (such as URLs) so that they provide a consistent search experience.
*/
public static normalizeFilters(filters: EventsFilter[]): EventsFilter[] {
const eventsQueryFilters: EventsFilter[] = [];
// normalize each filter individually by the type of filter it is.
for (const filter of filters) {
let eventsFilter: EventsFilter;
if (this.isRecordsFilter(filter)) {
eventsFilter = Records.normalizeFilter(filter);
} else {
// no normalization needed
eventsFilter = filter;
}
// remove any empty filter properties and do not add if empty
removeUndefinedProperties(eventsFilter);
if (!isEmptyObject(eventsFilter)) {
eventsQueryFilters.push(eventsFilter);
}
}
return eventsQueryFilters;
}
/**
* Converts an incoming array of EventsFilter into an array of Filter usable by EventLog.
*
* @param filters An array of EventsFilter
* @returns {Filter[]} an array of generic Filter able to be used when querying.
*/
public static convertFilters(filters: EventsFilter[]): Filter[] {
const eventsQueryFilters: Filter[] = [];
// convert each filter individually by the specific type of filter it is
// we must check for the type of filter in a specific order to make a reductive decision as to which filters need converting
// first we check for `EventsRecordsFilter` fields for conversion
// otherwise it is `EventsMessageFilter` fields for conversion
for (const filter of filters) {
if (this.isRecordsFilter(filter)) {
eventsQueryFilters.push(Records.convertFilter(filter));
} else {
eventsQueryFilters.push(this.convertFilter(filter));
}
}
return eventsQueryFilters;
}
/**
* Converts an external-facing filter model into an internal-facing filer model used by data store.
*/
private static convertFilter(filter: EventsMessageFilter): Filter {
const filterCopy = { ...filter } as Filter;
const { dateUpdated } = filter;
const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined;
if (messageTimestampFilter) {
filterCopy.messageTimestamp = messageTimestampFilter;
delete filterCopy.dateUpdated;
}
return filterCopy as Filter;
}
// we deliberately do not check for `dateUpdated` in this filter.
// if it were the only property that matched, it could be handled by `EventsFilter`
private static isRecordsFilter(filter: EventsFilter): filter is EventsRecordsFilter {
return 'author' in filter ||
'dateCreated' in filter ||
'dataFormat' in filter ||
'dataSize' in filter ||
'parentId' in filter ||
'recordId' in filter ||
'schema' in filter ||
'protocol' in filter ||
'protocolPath' in filter ||
'recipient' in filter;
}
}
@@ -0,0 +1,245 @@
import type { EqualFilter, Filter, FilterValue, KeyValues, OneOfFilter, RangeCriterion, RangeFilter, RangeValue } from '../types/query-types.js';
/**
* A Utility class to help match indexes against filters.
*/
export class FilterUtility {
/**
* Matches the given key values against an array of filters, if any of the filters match, returns true.
*
* @returns true if any of the filters match.
*/
static matchAnyFilter(keyValues: KeyValues, orFilters: Filter[]): boolean {
if (orFilters.length === 0) {
return true;
}
for (const filter of orFilters) {
// if any of the filters match the indexed values, we return true as it's a match
if (this.matchFilter(keyValues, filter)) {
return true;
}
}
return false;
}
/**
* Evaluates the given filter against the indexed values.
*
* @param indexedValues the indexed values for an item.
* @param filter
* @returns true if all of the filter properties match.
*/
public static matchFilter(indexedValues: KeyValues, filter: Filter): boolean {
// we loop through each of the filter properties to check against the indexed values.
// if any of them do not match we return false.
for (const filterProperty in filter) {
const filterValue = filter[filterProperty];
const indexValue = indexedValues[filterProperty];
if (indexValue === undefined) {
return false;
}
const matched = Array.isArray(indexValue) ?
this.matchAnyIndexValue(filterValue, indexValue) :
this.matchIndexValue(filterValue, indexValue);
if (!matched) {
return false;
}
}
return true;
}
/**
* Returns true if any of the index values match the filter.
*
* @param filterValue the filter for a particular property.
* @param indexValues an array of values to match the filter against.
*/
private static matchAnyIndexValue(filterValue: FilterValue, indexValues: string[] | number[] | boolean[]): boolean {
for (const indexValue of indexValues) {
if (this.matchIndexValue(filterValue, indexValue)) {
return true;
}
}
return false;
}
/**
* Returns true if the filter matches the given index value.
*
* @param filterValue the filter for a particular property.
* @param indexValue a single value to match the filter against.
*/
private static matchIndexValue(filterValue: FilterValue, indexValue: string | number | boolean) : boolean {
if (typeof filterValue === 'object') {
if (Array.isArray(filterValue)) {
// if `filterValue` is an array, it is a OneOfFilter
// Support OR matches by querying for each values separately,
if (this.matchOneOf(filterValue, indexValue)) {
return true;
}
} else {
// `filterValue` is a `RangeFilter`
// range filters cannot range over booleans
if (this.matchRange(filterValue, indexValue as RangeValue)) {
return true;
}
}
} else {
// filterValue is an EqualFilter, meaning it is a non-object primitive type
if (indexValue === filterValue) {
return true;
}
}
return false;
}
/**
* Evaluates a OneOfFilter given an indexedValue extracted from the index.
*
* @param filter An array of EqualFilters. Treated as an OR.
* @param indexedValue the indexed value being compared.
* @returns true if any of the given filters match the indexedValue
*/
private static matchOneOf(filter: OneOfFilter, indexedValue: string | number | boolean): boolean {
for (const orFilterValue of filter) {
if (indexedValue === orFilterValue) {
return true;
}
}
return false;
}
/**
* Evaluates if the given indexedValue is within the range given by the RangeFilter.
*
* @returns true if all of the range filter conditions are met.
*/
private static matchRange(rangeFilter: RangeFilter, indexedValue: string | number): boolean {
if (rangeFilter.lt !== undefined && indexedValue >= rangeFilter.lt) {
return false;
}
if (rangeFilter.lte !== undefined && indexedValue > rangeFilter.lte) {
return false;
}
if (rangeFilter.gt !== undefined && indexedValue <= rangeFilter.gt) {
return false;
}
if (rangeFilter.gte !== undefined && indexedValue < rangeFilter.gte) {
return false;
}
return true;
}
static isEqualFilter(filter: FilterValue): filter is EqualFilter {
if (typeof filter !== 'object') {
return true;
}
return false;
}
static isRangeFilter(filter: FilterValue): filter is RangeFilter {
if (typeof filter === 'object' && !Array.isArray(filter)) {
return 'gt' in filter || 'lt' in filter || 'lte' in filter || 'gte' in filter;
};
return false;
}
static isOneOfFilter(filter: FilterValue): filter is OneOfFilter {
if (typeof filter === 'object' && Array.isArray(filter)) {
return true;
};
return false;
}
static convertRangeCriterion(inputFilter: RangeCriterion): RangeFilter | undefined {
let rangeFilter: RangeFilter | undefined;
if (inputFilter.to !== undefined && inputFilter.from !== undefined) {
rangeFilter = {
gte : inputFilter.from,
lt : inputFilter.to,
};
} else if (inputFilter.to !== undefined) {
rangeFilter = {
lt: inputFilter.to,
};
} else if (inputFilter.from !== undefined) {
rangeFilter = {
gte: inputFilter.from,
};
}
return rangeFilter;
}
static constructPrefixFilterAsRangeFilter(prefix: string): RangeFilter {
return {
gte : prefix,
lt : prefix + '\uffff',
};
}
}
export class FilterSelector {
/**
* Reduce Filter so that it is a filter that can be quickly executed against the DB.
*/
static reduceFilter(filter: Filter): Filter {
// if there is only one or no property, we have no way to reduce it further
const filterProperties = Object.keys(filter);
if (filterProperties.length <= 1) {
return filter;
}
// else there is are least 2 filter properties, since zero property is not allowed
const { recordId, attester, parentId, recipient, contextId, author, protocolPath, schema, protocol, ...remainingProperties } = filter;
if (recordId !== undefined) {
return { recordId };
}
if (attester !== undefined) {
return { attester };
}
if (parentId !== undefined) {
return { parentId };
}
if (recipient !== undefined) {
return { recipient };
}
if (contextId !== undefined) {
return { contextId };
}
if (protocolPath !== undefined) {
return { protocolPath };
}
if (schema !== undefined) {
return { schema };
}
if (protocol !== undefined) {
return { protocol };
}
// else just return whatever property, we can optimize further later
const remainingPropertyNames = Object.keys(remainingProperties);
const firstRemainingProperty = remainingPropertyNames[0];
const singlePropertyFilter: Filter = {};
singlePropertyFilter[firstRemainingProperty] = filter[firstRemainingProperty];
return singlePropertyFilter;
}
}
@@ -0,0 +1,126 @@
import type { PrivateJwk, PublicJwk } from '../types/jose-types.js';
import { Encoder } from './encoder.js';
import { getWebcryptoSubtle } from '@noble/ciphers/webcrypto';
import { Secp256k1 } from './secp256k1.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
export enum KeyDerivationScheme {
/**
* Key derivation using the `dataFormat` value for Flat-space records.
*/
DataFormats = 'dataFormats',
ProtocolContext = 'protocolContext',
ProtocolPath = 'protocolPath',
/**
* Key derivation using the `schema` value for Flat-space records.
*/
Schemas = 'schemas'
}
export type DerivedPrivateJwk = {
rootKeyId: string,
derivationScheme: KeyDerivationScheme;
derivationPath?: string[];
derivedPrivateKey: PrivateJwk,
};
/**
* Class containing hierarchical deterministic key related utility methods used by the DWN.
*/
export class HdKey {
/**
* Derives a descendant private key.
* NOTE: currently only supports SECP256K1 keys.
*/
public static async derivePrivateKey(ancestorKey: DerivedPrivateJwk, subDerivationPath: string[]): Promise<DerivedPrivateJwk> {
const ancestorPrivateKey = Secp256k1.privateJwkToBytes(ancestorKey.derivedPrivateKey);
const ancestorPrivateKeyDerivationPath = ancestorKey.derivationPath ?? [];
const derivedPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(ancestorPrivateKey, subDerivationPath);
const derivedPrivateJwk = await Secp256k1.privateKeyToJwk(derivedPrivateKeyBytes);
const derivedDescendantPrivateKey: DerivedPrivateJwk = {
rootKeyId : ancestorKey.rootKeyId,
derivationScheme : ancestorKey.derivationScheme,
derivationPath : [...ancestorPrivateKeyDerivationPath, ...subDerivationPath],
derivedPrivateKey : derivedPrivateJwk
};
return derivedDescendantPrivateKey;
}
/**
* Derives a descendant public key from an ancestor private key.
* NOTE: currently only supports SECP256K1 keys.
*/
public static async derivePublicKey(ancestorKey: DerivedPrivateJwk, subDerivationPath: string[]): Promise<PublicJwk> {
const derivedDescendantPrivateKey = await HdKey.derivePrivateKey(ancestorKey, subDerivationPath);
const derivedDescendantPublicKey = await Secp256k1.getPublicJwk(derivedDescendantPrivateKey.derivedPrivateKey);
return derivedDescendantPublicKey;
}
/**
* Derives a hardened hierarchical deterministic private key.
*/
public static async derivePrivateKeyBytes(privateKey: Uint8Array, relativePath: string[]): Promise<Uint8Array> {
HdKey.validateKeyDerivationPath(relativePath);
let currentPrivateKey = privateKey;
for (const segment of relativePath) {
const segmentBytes = Encoder.stringToBytes(segment);
currentPrivateKey = await HdKey.deriveKeyUsingHkdf({
hashAlgorithm : 'SHA-256',
initialKeyMaterial : currentPrivateKey,
info : segmentBytes, // use the segment as the application specific info for key derivation
keyLengthInBytes : 32 // 32 bytes = 256 bits
});
}
return currentPrivateKey;
}
/**
* Derives a key using HMAC-based Extract-and-Expand Key Derivation Function (HKDF) as defined in RFC 5869.
* TODO: Consolidate HKDF implementation and usage with web5-js - https://github.com/TBD54566975/dwn-sdk-js/issues/742
*/
public static async deriveKeyUsingHkdf(params: {
hashAlgorithm: 'SHA-256' | 'SHA-384' | 'SHA-512',
initialKeyMaterial: Uint8Array,
info: Uint8Array,
keyLengthInBytes: number
}): Promise<Uint8Array> {
const { hashAlgorithm, initialKeyMaterial, info, keyLengthInBytes } = params;
const webCrypto = getWebcryptoSubtle() as SubtleCrypto;
// Import the `initialKeyMaterial` into the Web Crypto API to use for the key derivation operation.
const webCryptoKey = await webCrypto.importKey('raw', initialKeyMaterial, { name: 'HKDF' }, false, ['deriveBits']);
// Derive the bytes using the Web Crypto API.
const derivedKeyBuffer = await crypto.subtle.deriveBits(
{
name : 'HKDF',
hash : hashAlgorithm,
salt : new Uint8Array(0), // `info` should be sufficient in our use case
info
},
webCryptoKey,
keyLengthInBytes * 8 // convert from bytes to bits
);
// Convert from ArrayBuffer to Uint8Array.
const derivedKeyBytes = new Uint8Array(derivedKeyBuffer);
return derivedKeyBytes;
}
/**
* Validates that no empty strings exist within the derivation path segments array.
* @throws {DwnError} with `DwnErrorCode.HdKeyDerivationPathInvalid` if derivation path fails validation.
*/
private static validateKeyDerivationPath(pathSegments: string[]): void {
if (pathSegments.includes('')) {
throw new DwnError(DwnErrorCode.HdKeyDerivationPathInvalid, `Invalid key derivation path: ${pathSegments}`);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { GeneralJws } from '../types/jws-types.js';
import type { SignatureEntry } from '../types/jws-types.js';
import type { Signer } from '../types/signer.js';
import type { KeyMaterial, PublicJwk } from '../types/jose-types.js';
import isPlainObject from 'lodash/isPlainObject.js';
import { Encoder } from './encoder.js';
import { PrivateKeySigner } from './private-key-signer.js';
import { signatureAlgorithms } from '../jose/algorithms/signing/signature-algorithms.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
/**
* Utility class for JWS related operations.
*/
export class Jws {
/**
* Gets the `kid` from a general JWS signature entry.
*/
public static getKid(signatureEntry: SignatureEntry): string {
const { kid } = Encoder.base64UrlToObject(signatureEntry.protected);
return kid;
}
/**
* Gets the signer DID from a general JWS signature entry.
*/
public static getSignerDid(signatureEntry: SignatureEntry): string {
const kid = Jws.getKid(signatureEntry);
const did = Jws.extractDid(kid);
return did;
}
/**
* Verifies the signature against the given payload.
* @returns `true` if signature is valid; `false` otherwise
*/
public static async verifySignature(base64UrlPayload: string, signatureEntry: SignatureEntry, jwkPublic: PublicJwk): Promise<boolean> {
const signatureAlgorithm = signatureAlgorithms[jwkPublic.crv];
if (!signatureAlgorithm) {
throw new DwnError(DwnErrorCode.JwsVerifySignatureUnsupportedCrv, `unsupported crv. crv must be one of ${Object.keys(signatureAlgorithms)}`);
}
const payload = Encoder.stringToBytes(`${signatureEntry.protected}.${base64UrlPayload}`);
const signatureBytes = Encoder.base64UrlToBytes(signatureEntry.signature);
return await signatureAlgorithm.verify(payload, signatureBytes, jwkPublic);
}
/**
* Decodes the payload of the given JWS object as a plain object.
*/
public static decodePlainObjectPayload(jws: GeneralJws): any {
let payloadJson;
try {
payloadJson = Encoder.base64UrlToObject(jws.payload);
} catch {
throw new DwnError(DwnErrorCode.JwsDecodePlainObjectPayloadInvalid, 'payload is not a JSON object');
}
if (!isPlainObject(payloadJson)) {
throw new DwnError(DwnErrorCode.JwsDecodePlainObjectPayloadInvalid, 'signed payload must be a plain object');
}
return payloadJson;
}
/**
* Extracts the DID from the given `kid` string.
*/
public static extractDid(kid: string): string {
const [ did ] = kid.split('#');
return did;
}
/**
* Creates a Signer[] from the given Personas.
*/
public static createSigners(keyMaterials: KeyMaterial[]): Signer[] {
const signers = keyMaterials.map((keyMaterial) => Jws.createSigner(keyMaterial));
return signers;
}
/**
* Creates a Signer from the given Persona.
*/
public static createSigner(keyMaterial: KeyMaterial): Signer {
const privateJwk = keyMaterial.keyPair.privateJwk;
const keyId = keyMaterial.keyId;
const signer = new PrivateKeySigner({ privateJwk, keyId });
return signer;
}
}
@@ -0,0 +1,31 @@
import type { Cache } from '../types/cache.js';
import { LRUCache } from 'lru-cache';
/**
* A cache using local memory.
*/
export class MemoryCache implements Cache {
private cache: LRUCache<string, any>;
/**
* @param timeToLiveInSeconds time-to-live for every key-value pair set in the cache
*/
public constructor (private timeToLiveInSeconds: number) {
this.cache = new LRUCache({
max : 100_000,
ttl : timeToLiveInSeconds * 1000
});
}
async set(key: string, value: any): Promise<void> {
try {
this.cache.set(key, value);
} catch {
// let the code continue as this is a non-fatal error
}
}
async get(key: string): Promise<any | undefined> {
return this.cache.get(key);
}
}
@@ -0,0 +1,43 @@
/**
* Checks whether the given object has any properties.
*/
export function isEmptyObject(obj: unknown): boolean {
if (typeof(obj) !== 'object') {
return false;
}
for (const _ in obj) {
return false;
}
return true;
}
/**
* Recursively removes all properties with an empty object or array as its value from the given object.
*/
export function removeEmptyObjects(obj: Record<string, unknown>): void {
Object.keys(obj).forEach(key => {
if (typeof(obj[key]) === 'object') {
// recursive remove empty object or array properties in nested objects
removeEmptyObjects(obj[key] as Record<string, unknown>);
}
if (isEmptyObject(obj[key])) {
delete obj[key];
}
});
}
/**
* Recursively removes all properties with `undefined` as its value from the given object.
*/
export function removeUndefinedProperties(obj: Record<string, unknown>): void {
Object.keys(obj).forEach(key => {
if (obj[key] === undefined) {
delete obj[key];
} else if (typeof(obj[key]) === 'object') {
removeUndefinedProperties(obj[key] as Record<string, unknown>); // recursive remove `undefined` properties in nested objects
}
});
}
@@ -0,0 +1,72 @@
import type { PrivateJwk } from '../types/jose-types.js';
import type { Signer } from '../types/signer.js';
import { signatureAlgorithms } from '../jose/algorithms/signing/signature-algorithms.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
/**
* Input to `PrivateKeySigner` constructor.
*/
export type PrivateKeySignerOptions = {
/**
* Private JWK to create the signer from.
*/
privateJwk: PrivateJwk;
/**
* If not specified, the constructor will attempt to default/fall back to the `kid` value in the given `privateJwk`.
*/
keyId?: string;
/**
* If not specified, the constructor will attempt to default/fall back to the `alg` value in the given `privateJwk`.
*/
algorithm?: string;
};
/**
* A signer that signs using a private key.
*/
export class PrivateKeySigner implements Signer {
public keyId;
public algorithm;
private privateJwk: PrivateJwk;
private signatureAlgorithm;
public constructor(options: PrivateKeySignerOptions) {
if (options.keyId === undefined && options.privateJwk.kid === undefined) {
throw new DwnError(
DwnErrorCode.PrivateKeySignerUnableToDeduceKeyId,
`Unable to deduce the key ID`
);
}
// NOTE: `alg` is optional for a JWK as specified in https://datatracker.ietf.org/doc/html/rfc7517#section-4.4
if (options.algorithm === undefined && options.privateJwk.alg === undefined) {
throw new DwnError(
DwnErrorCode.PrivateKeySignerUnableToDeduceAlgorithm,
`Unable to deduce the signature algorithm`
);
}
this.keyId = options.keyId ?? options.privateJwk.kid!;
this.algorithm = options.algorithm ?? options.privateJwk.alg!;
this.privateJwk = options.privateJwk;
this.signatureAlgorithm = signatureAlgorithms[options.privateJwk.crv];
if (!this.signatureAlgorithm) {
throw new DwnError(
DwnErrorCode.PrivateKeySignerUnsupportedCurve,
`Unsupported crv ${options.privateJwk.crv}, crv must be one of ${Object.keys(signatureAlgorithms)}`
);
}
}
/**
* Signs the given content and returns the signature as bytes.
*/
public async sign (content: Uint8Array): Promise<Uint8Array> {
const signatureBytes = await this.signatureAlgorithm.sign(content, this.privateJwk);
return signatureBytes;
}
}
@@ -0,0 +1,50 @@
import type { DerivedPrivateJwk } from '../utils/hd-key.js';
import type { PrivateJwk } from '../types/jose-types.js';
import type { ProtocolDefinition, ProtocolRuleSet } from '../types/protocols-types.js';
import { Secp256k1 } from './secp256k1.js';
import { HdKey, KeyDerivationScheme } from '../utils/hd-key.js';
/**
* Class containing Protocol related utility methods.
*/
export class Protocols {
/**
* Derives public encryptions keys and inject it in the `$encryption` property for each protocol path segment of the given Protocol definition,
* then returns the final encryption-enabled protocol definition.
* NOTE: The original definition passed in is unmodified.
*/
public static async deriveAndInjectPublicEncryptionKeys(
protocolDefinition: ProtocolDefinition,
rootKeyId: string,
privateJwk: PrivateJwk
): Promise<ProtocolDefinition> {
// clone before modify
const encryptionEnabledProtocolDefinition = JSON.parse(JSON.stringify(protocolDefinition)) as ProtocolDefinition;
// a function that recursively creates and adds `$encryption` property to every rule set
async function addEncryptionProperty(ruleSet: ProtocolRuleSet, parentKey: DerivedPrivateJwk): Promise<void> {
for (const key in ruleSet) {
// if we encounter a nested rule set (a property name that doesn't begin with '$'), recursively inject the `$encryption` property
if (!key.startsWith('$')) {
const derivedPrivateKey = await HdKey.derivePrivateKey(parentKey, [key]);
const publicKeyJwk = await Secp256k1.getPublicJwk(derivedPrivateKey.derivedPrivateKey);
ruleSet[key].$encryption = { rootKeyId, publicKeyJwk };
await addEncryptionProperty(ruleSet[key], derivedPrivateKey);
}
}
}
// inject encryption property starting from each root level record type
const rootKey: DerivedPrivateJwk = {
derivationScheme : KeyDerivationScheme.ProtocolPath,
derivedPrivateKey : privateJwk,
rootKeyId
};
const protocolLevelDerivedKey = await HdKey.derivePrivateKey(rootKey, [KeyDerivationScheme.ProtocolPath, protocolDefinition.protocol]);
await addEncryptionProperty(encryptionEnabledProtocolDefinition.structure, protocolLevelDerivedKey);
return encryptionEnabledProtocolDefinition;
}
}
@@ -0,0 +1,512 @@
import type { DerivedPrivateJwk } from './hd-key.js';
import type { Readable } from 'readable-stream';
import type { Filter, KeyValues, StartsWithFilter } from '../types/query-types.js';
import type { GenericMessage, GenericSignaturePayload } from '../types/message-types.js';
import type { RecordsDeleteMessage, RecordsFilter, RecordsQueryMessage, RecordsReadMessage, RecordsSubscribeMessage, RecordsWriteDescriptor, RecordsWriteMessage, RecordsWriteTags, RecordsWriteTagsFilter } from '../types/records-types.js';
import { DateSort } from '../types/records-types.js';
import { Encoder } from './encoder.js';
import { Encryption } from './encryption.js';
import { FilterUtility } from './filter.js';
import { Jws } from './jws.js';
import { Message } from '../core/message.js';
import { PermissionGrant } from '../protocols/permission-grant.js';
import { removeUndefinedProperties } from './object.js';
import { Secp256k1 } from './secp256k1.js';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
import { HdKey, KeyDerivationScheme } from './hd-key.js';
import { normalizeProtocolUrl, normalizeSchemaUrl } from './url.js';
/**
* Class containing useful utilities related to the Records interface.
*/
export class Records {
/**
* Checks if the given message is a `RecordsWriteMessage`.
*/
public static isRecordsWrite(message: GenericMessage): message is RecordsWriteMessage {
const isRecordsWrite =
message.descriptor.interface === DwnInterfaceName.Records &&
message.descriptor.method === DwnMethodName.Write;
return isRecordsWrite;
}
/**
* Gets the DID of the author of the given message.
*/
public static getAuthor(message: RecordsWriteMessage | RecordsDeleteMessage): string | undefined {
let author;
if (message.authorization.authorDelegatedGrant !== undefined) {
author = Message.getSigner(message.authorization.authorDelegatedGrant);
} else {
author = Message.getSigner(message);
}
return author;
}
/**
* Decrypts the encrypted data in a message reply using the given ancestor private key.
* @param ancestorPrivateKey Any ancestor private key in the key derivation path.
*/
public static async decrypt(
recordsWrite: RecordsWriteMessage,
ancestorPrivateKey: DerivedPrivateJwk,
cipherStream: Readable
): Promise<Readable> {
const { encryption } = recordsWrite;
// look for an encrypted symmetric key that is encrypted by the public key corresponding to the given private key
const matchingEncryptedKey = encryption!.keyEncryption.find(key =>
key.rootKeyId === ancestorPrivateKey.rootKeyId &&
key.derivationScheme === ancestorPrivateKey.derivationScheme
);
if (matchingEncryptedKey === undefined) {
throw new DwnError(
DwnErrorCode.RecordsDecryptNoMatchingKeyEncryptedFound,
`Unable to find a symmetric key encrypted using key \
with ID '${ancestorPrivateKey.rootKeyId}' and '${ancestorPrivateKey.derivationScheme}' derivation scheme.`
);
}
const fullDerivationPath = Records.constructKeyDerivationPath(matchingEncryptedKey.derivationScheme, recordsWrite);
// NOTE: right now only `ECIES-ES256K` algorithm is supported for asymmetric encryption,
// so we will assume that's the algorithm without additional switch/if statements
const leafPrivateKey = await Records.derivePrivateKey(ancestorPrivateKey, fullDerivationPath);
const encryptedKeyBytes = Encoder.base64UrlToBytes(matchingEncryptedKey.encryptedKey);
const ephemeralPublicKey = Secp256k1.publicJwkToBytes(matchingEncryptedKey.ephemeralPublicKey);
const keyEncryptionInitializationVector = Encoder.base64UrlToBytes(matchingEncryptedKey.initializationVector);
const messageAuthenticationCode = Encoder.base64UrlToBytes(matchingEncryptedKey.messageAuthenticationCode);
const dataEncryptionKey = await Encryption.eciesSecp256k1Decrypt({
ciphertext : encryptedKeyBytes,
ephemeralPublicKey,
initializationVector : keyEncryptionInitializationVector,
messageAuthenticationCode,
privateKey : leafPrivateKey
});
// NOTE: right now only `A256CTR` algorithm is supported for symmetric encryption,
// so we will assume that's the algorithm without additional switch/if statements
const dataEncryptionInitializationVector = Encoder.base64UrlToBytes(encryption!.initializationVector);
const plaintextStream = await Encryption.aes256CtrDecrypt(dataEncryptionKey, dataEncryptionInitializationVector, cipherStream);
return plaintextStream;
}
/**
* Constructs full key derivation path using the specified scheme.
*/
public static constructKeyDerivationPath(
keyDerivationScheme: KeyDerivationScheme,
recordsWriteMessage: RecordsWriteMessage
): string[] {
const descriptor = recordsWriteMessage.descriptor;
const contextId = recordsWriteMessage.contextId;
let fullDerivationPath;
if (keyDerivationScheme === KeyDerivationScheme.DataFormats) {
fullDerivationPath = Records.constructKeyDerivationPathUsingDataFormatsScheme(descriptor.schema, descriptor.dataFormat);
} else if (keyDerivationScheme === KeyDerivationScheme.ProtocolPath) {
fullDerivationPath = Records.constructKeyDerivationPathUsingProtocolPathScheme(descriptor);
} else if (keyDerivationScheme === KeyDerivationScheme.ProtocolContext) {
fullDerivationPath = Records.constructKeyDerivationPathUsingProtocolContextScheme(contextId);
} else {
// `schemas` scheme
fullDerivationPath = Records.constructKeyDerivationPathUsingSchemasScheme(descriptor.schema);
}
return fullDerivationPath;
}
/**
* Constructs the full key derivation path using `dataFormats` scheme.
*/
public static constructKeyDerivationPathUsingDataFormatsScheme(schema: string | undefined, dataFormat: string ): string[] {
if (schema !== undefined) {
return [
KeyDerivationScheme.DataFormats,
schema, // this is as spec-ed on TP27, the intent is to support sharing the key for just a specific data type under a schema
dataFormat
];
} else {
return [
KeyDerivationScheme.DataFormats,
dataFormat
];
}
}
/**
* Constructs the full key derivation path using `protocolPath` scheme.
*/
public static constructKeyDerivationPathUsingProtocolPathScheme(descriptor: RecordsWriteDescriptor): string[] {
// ensure `protocol` is defined
// NOTE: no need to check `protocolPath` and `contextId` because earlier code ensures that if `protocol` is defined, those are defined also
if (descriptor.protocol === undefined) {
throw new DwnError(
DwnErrorCode.RecordsProtocolPathDerivationSchemeMissingProtocol,
'Unable to construct key derivation path using `protocols` scheme because `protocol` is missing.'
);
}
const protocolPathSegments = descriptor.protocolPath!.split('/');
const fullDerivationPath = [
KeyDerivationScheme.ProtocolPath,
descriptor.protocol,
...protocolPathSegments
];
return fullDerivationPath;
}
/**
* Constructs the full key derivation path using `protocolContext` scheme.
*/
public static constructKeyDerivationPathUsingProtocolContextScheme(contextId: string | undefined): string[] {
if (contextId === undefined) {
throw new DwnError(
DwnErrorCode.RecordsProtocolContextDerivationSchemeMissingContextId,
'Unable to construct key derivation path using `protocolContext` scheme because `contextId` is missing.'
);
}
// TODO: issue #683 -Extend key derivation support to include the full contextId (https://github.com/TBD54566975/dwn-sdk-js/issues/683)
const firstContextSegment = contextId.split('/')[0];
const fullDerivationPath = [
KeyDerivationScheme.ProtocolContext,
firstContextSegment
];
return fullDerivationPath;
}
/**
* Constructs the full key derivation path using `schemas` scheme.
*/
public static constructKeyDerivationPathUsingSchemasScheme( schema: string | undefined ): string[] {
if (schema === undefined) {
throw new DwnError(
DwnErrorCode.RecordsSchemasDerivationSchemeMissingSchema,
'Unable to construct key derivation path using `schemas` scheme because `schema` is missing.'
);
}
const fullDerivationPath = [
KeyDerivationScheme.Schemas,
schema
];
return fullDerivationPath;
}
/**
* Derives a descendant private key given an ancestor private key and the full absolute derivation path.
* NOTE: right now only `ECIES-ES256K` algorithm is supported for asymmetric encryption,
* so we will only derive SECP256K1 key without additional conditional checks
*/
public static async derivePrivateKey(ancestorPrivateKey: DerivedPrivateJwk, fullDescendantDerivationPath: string[]): Promise<Uint8Array> {
if (ancestorPrivateKey.derivedPrivateKey.crv !== 'secp256k1') {
throw new DwnError(
DwnErrorCode.RecordsDerivePrivateKeyUnSupportedCurve,
`Curve ${ancestorPrivateKey.derivedPrivateKey.crv} is not supported.`
);
}
const ancestorPrivateKeyDerivationPath = ancestorPrivateKey.derivationPath ?? [];
Records.validateAncestorKeyAndDescentKeyDerivationPathsMatch(ancestorPrivateKeyDerivationPath, fullDescendantDerivationPath);
const subDerivationPath = fullDescendantDerivationPath.slice(ancestorPrivateKeyDerivationPath.length);
const ancestorPrivateKeyBytes = Secp256k1.privateJwkToBytes(ancestorPrivateKey.derivedPrivateKey);
const leafPrivateKey = await HdKey.derivePrivateKeyBytes(ancestorPrivateKeyBytes, subDerivationPath);
return leafPrivateKey;
}
/**
* Validates that ancestor derivation path matches the descendant derivation path completely.
* @throws {DwnError} with `DwnErrorCode.RecordsInvalidAncestorKeyDerivationSegment` if fails validation.
*/
public static validateAncestorKeyAndDescentKeyDerivationPathsMatch(
ancestorKeyDerivationPath: string[],
descendantKeyDerivationPath: string[]
): void {
for (let i = 0; i < ancestorKeyDerivationPath.length; i++) {
const ancestorSegment = ancestorKeyDerivationPath[i];
const descendantSegment = descendantKeyDerivationPath[i];
if (ancestorSegment !== descendantSegment) {
throw new DwnError(
DwnErrorCode.RecordsInvalidAncestorKeyDerivationSegment,
`Ancestor key derivation segment '${ancestorSegment}' mismatches against the descendant key derivation segment '${descendantSegment}'.`);
}
}
}
/**
* Extracts the parent context ID from the given context ID.
*/
public static getParentContextFromOfContextId(contextId: string | undefined): string | undefined {
if (contextId === undefined) {
return undefined;
}
// NOTE: assumes the given contextId is a valid contextId in the form of `a/b/c/d`.
// `/a/b/c/d` or `a/b/c/d/` is not supported.
const lastIndex = contextId.lastIndexOf('/');
// If '/' is not found, this means this is a root record, so return an empty string as the parent context ID.
if (lastIndex === -1) {
return '';
} else {
return contextId.substring(0, lastIndex);
}
}
/**
* Normalizes the protocol and schema URLs within a provided RecordsFilter and returns a copy of RecordsFilter with the modified values.
*
* @param filter incoming RecordsFilter to normalize.
* @returns {RecordsFilter} a copy of the incoming RecordsFilter with the normalized properties.
*/
public static normalizeFilter(filter: RecordsFilter): RecordsFilter {
let protocol;
if (filter.protocol === undefined) {
protocol = undefined;
} else {
protocol = normalizeProtocolUrl(filter.protocol);
}
let schema;
if (filter.schema === undefined) {
schema = undefined;
} else {
schema = normalizeSchemaUrl(filter.schema);
}
const filterCopy = {
...filter,
protocol,
schema,
};
removeUndefinedProperties(filterCopy);
return filterCopy;
}
public static isStartsWithFilter(filter: RecordsWriteTagsFilter): filter is StartsWithFilter {
return typeof filter === 'object' && ('startsWith' in filter && typeof filter.startsWith === 'string');
}
/**
* This will create individual keys for each of the tags that look like `tag.tag_property`
*/
public static buildTagIndexes(tags: RecordsWriteTags): KeyValues {
const tagValues:KeyValues = {};
for (const property in tags) {
const value = tags[property];
tagValues[`tag.${property}`] = value;
}
return tagValues;
}
/**
* This will create individual keys for each of the tag filters that look like `tag.tag_filter_property`
*/
private static convertTagsFilter( tags: { [property: string]: RecordsWriteTagsFilter}): Filter {
const tagValues:Filter = {};
for (const property in tags) {
const value = tags[property];
tagValues[`tag.${property}`] = this.isStartsWithFilter(value) ? FilterUtility.constructPrefixFilterAsRangeFilter(value.startsWith) : value;
}
return tagValues;
}
/**
* Converts an incoming RecordsFilter into a Filter usable by MessageStore.
*
* @param filter A RecordsFilter
* @returns {Filter} a generic Filter able to be used with MessageStore.
*/
public static convertFilter(filter: RecordsFilter, dateSort?: DateSort): Filter {
// we process tags separately from the remaining filters.
// this is because we prepend each field within the `tags` object with a `tag.` to avoid name clashing with first-class index keys.
// so `{ tags: { tag1: 'val1', tag2: [1,2] }}` would translate to `'tag.tag1':'val1'` and `'tag.tag2': [1,2]`
const { tags, ...remainingFilter } = filter;
let tagsFilter: Filter = {};
if (tags !== undefined) {
// this will namespace the tags so the properties are filtered as `tag.property_name`
tagsFilter = { ...this.convertTagsFilter(tags) };
}
const filterCopy = { ...remainingFilter, ...tagsFilter } as Filter;
// extract properties that needs conversion
const { dateCreated, datePublished, dateUpdated, contextId } = filter;
const dateCreatedFilter = dateCreated ? FilterUtility.convertRangeCriterion(dateCreated) : undefined;
if (dateCreatedFilter) {
filterCopy.dateCreated = dateCreatedFilter;
}
const datePublishedFilter = datePublished ? FilterUtility.convertRangeCriterion(datePublished): undefined;
if (datePublishedFilter) {
// only return published records when filtering with a datePublished range.
filterCopy.published = true;
filterCopy.datePublished = datePublishedFilter;
}
// if we sort by `PublishedAscending` or `PublishedDescending` we must filter for only published records.
if (filterCopy.published !== true && (dateSort === DateSort.PublishedAscending || dateSort === DateSort.PublishedDescending)) {
filterCopy.published = true;
}
const messageTimestampFilter = dateUpdated ? FilterUtility.convertRangeCriterion(dateUpdated) : undefined;
if (messageTimestampFilter) {
filterCopy.messageTimestamp = messageTimestampFilter;
delete filterCopy.dateUpdated;
}
// contextId conversion to prefix match
const contextIdPrefixFilter = contextId ? FilterUtility.constructPrefixFilterAsRangeFilter(contextId) : undefined;
if (contextIdPrefixFilter) {
filterCopy.contextId = contextIdPrefixFilter;
}
return filterCopy as Filter;
}
/**
* Validates the referential integrity of both author-delegated grant and owner-delegated grant.
* @param authorSignaturePayload Decoded payload of the author signature of the message. Pass `undefined` if message is not signed.
* Passed purely as a performance optimization so we don't have to decode the signature payload again.
* @param ownerSignaturePayload Decoded payload of the owner signature of the message. Pass `undefined` if no owner signature is present.
* Passed purely as a performance optimization so we don't have to decode the owner signature payload again.
*/
public static async validateDelegatedGrantReferentialIntegrity(
message: RecordsReadMessage | RecordsQueryMessage | RecordsWriteMessage | RecordsDeleteMessage | RecordsSubscribeMessage,
authorSignaturePayload: GenericSignaturePayload | undefined,
ownerSignaturePayload?: GenericSignaturePayload | undefined
): Promise<void> {
// `deletedGrantId` in the payload of the message signature and `authorDelegatedGrant` in `authorization` must both exist or be both undefined
const authorDelegatedGrantIdDefined = authorSignaturePayload?.delegatedGrantId !== undefined;
const authorDelegatedGrantDefined = message.authorization?.authorDelegatedGrant !== undefined;
if (authorDelegatedGrantIdDefined !== authorDelegatedGrantDefined) {
throw new DwnError(
DwnErrorCode.RecordsAuthorDelegatedGrantAndIdExistenceMismatch,
`delegatedGrantId in message (author) signature and authorDelegatedGrant must both exist or be undefined. \
delegatedGrantId in message (author) signature defined: ${authorDelegatedGrantIdDefined}, \
authorDelegatedGrant defined: ${authorDelegatedGrantDefined}`
);
}
if (authorDelegatedGrantDefined) {
const delegatedGrant = message.authorization!.authorDelegatedGrant!;
const permissionGrant = await PermissionGrant.parse(delegatedGrant);
if (permissionGrant.delegated !== true) {
throw new DwnError(
DwnErrorCode.RecordsAuthorDelegatedGrantNotADelegatedGrant,
`The owner delegated grant given is not a delegated grant.`
);
}
const grantedTo = delegatedGrant.descriptor.recipient;
const signer = Message.getSigner(message);
if (grantedTo !== signer) {
throw new DwnError(
DwnErrorCode.RecordsAuthorDelegatedGrantGrantedToAndOwnerSignatureMismatch,
`grantedTo ${grantedTo} in author delegated grant must be the same as the signer ${signer} of the message signature.`
);
}
const delegateGrantCid = await Message.getCid(delegatedGrant);
if (delegateGrantCid !== authorSignaturePayload!.delegatedGrantId) {
throw new DwnError(
DwnErrorCode.RecordsAuthorDelegatedGrantCidMismatch,
`CID of the author delegated grant ${delegateGrantCid} must be the same as \
the delegatedGrantId ${authorSignaturePayload!.delegatedGrantId} in the message signature.`
);
}
}
// repeat the same checks for the owner signature below
// `deletedGrantId` in the payload of the owner signature and `ownerDelegatedGrant` in `authorization` must both exist or be both undefined
const ownerDelegatedGrantIdDefined = ownerSignaturePayload?.delegatedGrantId !== undefined;
const ownerDelegatedGrantDefined = message.authorization?.ownerDelegatedGrant !== undefined;
if (ownerDelegatedGrantIdDefined !== ownerDelegatedGrantDefined) {
throw new DwnError(
DwnErrorCode.RecordsOwnerDelegatedGrantAndIdExistenceMismatch,
`delegatedGrantId in owner signature and ownerDelegatedGrant must both exist or be undefined. \
delegatedGrantId in owner signature defined: ${ownerDelegatedGrantIdDefined}, \
ownerDelegatedGrant defined: ${ownerDelegatedGrantDefined}`
);
}
if (ownerDelegatedGrantDefined) {
const delegatedGrant = message.authorization!.ownerDelegatedGrant!;
const permissionGrant = await PermissionGrant.parse(delegatedGrant);
if (permissionGrant.delegated !== true) {
throw new DwnError(
DwnErrorCode.RecordsOwnerDelegatedGrantNotADelegatedGrant,
`The owner delegated grant given is not a delegated grant.`
);
}
const grantedTo = delegatedGrant.descriptor.recipient;
const signer = Jws.getSignerDid(message.authorization!.ownerSignature!.signatures[0]);
if (grantedTo !== signer) {
throw new DwnError(
DwnErrorCode.RecordsOwnerDelegatedGrantGrantedToAndOwnerSignatureMismatch,
`grantedTo ${grantedTo} in owner delegated grant must be the same as the signer ${signer} of the owner signature.`
);
}
const delegateGrantCid = await Message.getCid(delegatedGrant);
if (delegateGrantCid !== ownerSignaturePayload!.delegatedGrantId) {
throw new DwnError(
DwnErrorCode.RecordsOwnerDelegatedGrantCidMismatch,
`CID of the owner delegated grant ${delegateGrantCid} must be the same as \
the delegatedGrantId ${ownerSignaturePayload!.delegatedGrantId} in the owner signature.`
);
}
}
}
/**
* Determines if signature payload contains a protocolRole and should be authorized as such.
*/
static shouldProtocolAuthorize(signaturePayload: GenericSignaturePayload): boolean {
return signaturePayload.protocolRole !== undefined;
}
/**
* Checks if the filter supports returning published records.
*/
static filterIncludesPublishedRecords(filter: RecordsFilter): boolean {
// NOTE: published records should still be returned when `published` and `datePublished` range are both undefined.
return filter.datePublished !== undefined || filter.published !== false;
}
/**
* Checks if the filter supports returning unpublished records.
*/
static filterIncludesUnpublishedRecords(filter: RecordsFilter): boolean {
// When `published` and `datePublished` range are both undefined, unpublished records can be returned.
if (filter.datePublished === undefined && filter.published === undefined) {
return true;
}
return filter.published === false;
}
}
@@ -0,0 +1,157 @@
import type { PrivateJwk, PublicJwk } from '../types/jose-types.js';
import * as secp256k1 from '@noble/secp256k1';
import { Encoder } from '../utils/encoder.js';
import { sha256 } from 'multiformats/hashes/sha2';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
/**
* Class containing SECP256K1 related utility methods.
*/
export class Secp256k1 {
/**
* Validates the given JWK is a SECP256K1 key.
* @throws {Error} if fails validation.
*/
public static validateKey(jwk: PrivateJwk | PublicJwk): void {
if (jwk.kty !== 'EC' || jwk.crv !== 'secp256k1') {
throw new DwnError(DwnErrorCode.Secp256k1KeyNotValid, 'Invalid SECP256K1 JWK: `kty` MUST be `EC`. `crv` MUST be `secp256k1`');
}
}
/**
* Converts a public key in bytes into a JWK.
*/
public static async publicKeyToJwk(publicKeyBytes: Uint8Array): Promise<PublicJwk> {
// ensure public key is in uncompressed format so we can convert it into both x and y value
let uncompressedPublicKeyBytes;
if (publicKeyBytes.byteLength === 33) {
// this means given key is compressed
const curvePoints = secp256k1.ProjectivePoint.fromHex(publicKeyBytes);
uncompressedPublicKeyBytes = curvePoints.toRawBytes(false); // isCompressed = false
} else {
uncompressedPublicKeyBytes = publicKeyBytes;
}
// the first byte is a header that indicates whether the key is uncompressed (0x04 if uncompressed), we can safely ignore
// bytes 1 - 32 represent X
// bytes 33 - 64 represent Y
// skip the first byte because it's used as a header to indicate whether the key is uncompressed
const x = Encoder.bytesToBase64Url(uncompressedPublicKeyBytes.subarray(1, 33));
const y = Encoder.bytesToBase64Url(uncompressedPublicKeyBytes.subarray(33, 65));
const publicJwk: PublicJwk = {
alg : 'ES256K',
kty : 'EC',
crv : 'secp256k1',
x,
y
};
return publicJwk;
}
/**
* Converts a private key in bytes into a JWK.
*/
public static async privateKeyToJwk(privateKeyBytes: Uint8Array): Promise<PrivateJwk> {
const publicKeyBytes = await Secp256k1.getPublicKey(privateKeyBytes);
const jwk = await Secp256k1.publicKeyToJwk(publicKeyBytes);
(jwk as PrivateJwk).d = Encoder.bytesToBase64Url(privateKeyBytes);
return jwk as PrivateJwk;
}
/**
* Creates a compressed key in raw bytes from the given SECP256K1 JWK.
*/
public static publicJwkToBytes(publicJwk: PublicJwk): Uint8Array {
const x = Encoder.base64UrlToBytes(publicJwk.x);
const y = Encoder.base64UrlToBytes(publicJwk.y!);
return secp256k1.ProjectivePoint.fromAffine({
x : secp256k1.etc.bytesToNumberBE(x),
y : secp256k1.etc.bytesToNumberBE(y)
}).toRawBytes(true);
}
/**
* Creates a private key in raw bytes from the given SECP256K1 JWK.
*/
public static privateJwkToBytes(privateJwk: PrivateJwk): Uint8Array {
const privateKey = Encoder.base64UrlToBytes(privateJwk.d);
return privateKey;
}
/**
* Signs the provided content using the provided JWK.
*/
public static async sign(content: Uint8Array, privateJwk: PrivateJwk): Promise<Uint8Array> {
Secp256k1.validateKey(privateJwk);
// the underlying lib expects us to hash the content ourselves:
// https://github.com/paulmillr/noble-secp256k1/blob/97aa518b9c12563544ea87eba471b32ecf179916/index.ts#L1160
const hashedContent = await sha256.encode(content);
const privateKeyBytes = Secp256k1.privateJwkToBytes(privateJwk);
return (await secp256k1.signAsync(hashedContent, privateKeyBytes)).toCompactRawBytes();
}
/**
* Verifies a signature against the provided payload hash and public key.
* @returns a boolean indicating whether the signature is valid.
*/
public static async verify(content: Uint8Array, signature: Uint8Array, publicJwk: PublicJwk): Promise<boolean> {
Secp256k1.validateKey(publicJwk);
const publicKeyBytes = Secp256k1.publicJwkToBytes(publicJwk);
const hashedContent = await sha256.encode(content);
return secp256k1.verify(signature, hashedContent, publicKeyBytes);
}
/**
* Generates a random key pair in JWK format.
*/
public static async generateKeyPair(): Promise<{publicJwk: PublicJwk, privateJwk: PrivateJwk}> {
const privateKeyBytes = secp256k1.utils.randomPrivateKey();
const publicKeyBytes = secp256k1.getPublicKey(privateKeyBytes, false); // `false` = uncompressed
const d = Encoder.bytesToBase64Url(privateKeyBytes);
const publicJwk: PublicJwk = await Secp256k1.publicKeyToJwk(publicKeyBytes);
const privateJwk: PrivateJwk = { ...publicJwk, d };
return { publicJwk, privateJwk };
}
/**
* Generates key pair in raw bytes, where the `publicKey` is compressed.
*/
public static async generateKeyPairRaw(): Promise<{publicKey: Uint8Array, privateKey: Uint8Array}> {
const privateKey = secp256k1.utils.randomPrivateKey();
const publicKey = secp256k1.getPublicKey(privateKey, true); // `true` = compressed
return { publicKey, privateKey };
}
/**
* Gets the compressed public key of the given private key.
*/
public static async getPublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const publicKey = secp256k1.getPublicKey(privateKey, true); // `true` = compressed
return publicKey;
}
/**
* Gets the public JWK of the given private JWK.
*/
public static async getPublicJwk(privateKeyJwk: PrivateJwk): Promise<PublicJwk> {
// strip away `d`
const { d: _d, ...publicKey } = privateKeyJwk;
return publicKey;
}
}
@@ -0,0 +1,142 @@
import type { PrivateJwk, PublicJwk } from '../types/jose-types.js';
import { p256, secp256r1 } from '@noble/curves/p256';
import { Encoder } from './encoder.js';
import { sha256 } from 'multiformats/hashes/sha2';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
import { fromString, toString } from 'uint8arrays';
const u8a = { toString, fromString };
/**
* Class containing SECP256R1 related utility methods.
*/
export class Secp256r1 {
/**
* Validates the given JWK is a SECP256R1 key.
* @throws {Error} if fails validation.
*/
public static validateKey(jwk: PrivateJwk | PublicJwk): void {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
throw new DwnError(
DwnErrorCode.Secp256r1KeyNotValid,
'Invalid SECP256R1 JWK: `kty` MUST be `EC`. `crv` MUST be `P-256`'
);
}
}
/**
* Converts a public key in bytes into a JWK.
*/
public static async publicKeyToJwk(
publicKeyBytes: Uint8Array
): Promise<PublicJwk> {
// ensure public key is in uncompressed format so we can convert it into both x and y value
let uncompressedPublicKeyBytes;
if (publicKeyBytes.byteLength === 33) {
// this means given key is compressed
const curvePoints = p256.ProjectivePoint.fromHex(publicKeyBytes);
uncompressedPublicKeyBytes = curvePoints.toRawBytes(false); // isCompressed = false
} else {
uncompressedPublicKeyBytes = publicKeyBytes;
}
// the first byte is a header that indicates whether the key is uncompressed (0x04 if uncompressed), we can safely ignore
// bytes 1 - 32 represent X
// bytes 33 - 64 represent Y
// skip the first byte because it's used as a header to indicate whether the key is uncompressed
const x = Encoder.bytesToBase64Url(
uncompressedPublicKeyBytes.subarray(1, 33)
);
const y = Encoder.bytesToBase64Url(
uncompressedPublicKeyBytes.subarray(33, 65)
);
const publicJwk: PublicJwk = {
alg : 'ES256',
kty : 'EC',
crv : 'P-256',
x,
y,
};
return publicJwk;
}
/**
* Creates a private key in raw bytes from the given SECP256R1 JWK.
*/
public static privateJwkToBytes(privateJwk: PrivateJwk): Uint8Array {
const privateKey = Encoder.base64UrlToBytes(privateJwk.d);
return privateKey;
}
/**
* Signs the provided content using the provided JWK.
* Signature that is outputted is JWS format, not DER.
*/
public static async sign(
content: Uint8Array,
privateJwk: PrivateJwk
): Promise<Uint8Array> {
Secp256r1.validateKey(privateJwk);
const hashedContent = await sha256.encode(content);
const privateKeyBytes = Secp256r1.privateJwkToBytes(privateJwk);
return Promise.resolve(
p256.sign(hashedContent, privateKeyBytes).toCompactRawBytes()
);
}
/**
* Verifies a signature against the provided payload hash and public key.
* @param signature - the signature to verify. Can be in either DER or compact format. If using Oracle Cloud KMS, keys will be DER formatted.
* @returns a boolean indicating whether the signature is valid.
*/
public static async verify(
content: Uint8Array,
signature: Uint8Array,
publicJwk: PublicJwk
): Promise<boolean> {
Secp256r1.validateKey(publicJwk);
// handle DER vs compact signature formats
let sig;
if (signature.length === 64) {
sig = p256.Signature.fromCompact(signature);
} else {
sig = p256.Signature.fromDER(signature);
}
const hashedContent = await sha256.encode(content);
const keyBytes = p256.ProjectivePoint.fromAffine({
x : Secp256r1.bytesToBigInt(Encoder.base64UrlToBytes(publicJwk.x)),
y : Secp256r1.bytesToBigInt(Encoder.base64UrlToBytes(publicJwk.y!)),
}).toRawBytes(false);
return p256.verify(sig, hashedContent, keyBytes);
}
/**
* Generates a random key pair in JWK format.
*/
public static async generateKeyPair(): Promise<{
publicJwk: PublicJwk;
privateJwk: PrivateJwk;
}> {
const privateKeyBytes = p256.utils.randomPrivateKey();
const publicKeyBytes = secp256r1.getPublicKey(privateKeyBytes, false); // `false` = uncompressed
const d = Encoder.bytesToBase64Url(privateKeyBytes);
const publicJwk: PublicJwk = await Secp256r1.publicKeyToJwk(publicKeyBytes);
const privateJwk: PrivateJwk = { ...publicJwk, d };
return { publicJwk, privateJwk };
}
public static bytesToBigInt(b: Uint8Array): bigint {
return BigInt(`0x` + u8a.toString(b, 'base16'));
}
}
@@ -0,0 +1,13 @@
/**
* Compares two string given in lexicographical order.
* @returns 1 if `a` is larger than `b`; -1 if `a` is smaller/older than `b`; 0 otherwise (same message)
*/
export function lexicographicalCompare(a: string, b: string): number {
if (a > b) {
return 1;
} else if (a < b) {
return -1;
} else {
return 0;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Temporal } from '@js-temporal/polyfill';
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
/**
* Time related utilities.
*/
export class Time {
/**
* sleeps for the desired duration
* @param durationInMillisecond the desired amount of sleep time
* @returns when the provided duration has passed
*/
public static async sleep(durationInMillisecond: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, durationInMillisecond));
}
/**
* We must sleep for at least 2ms to avoid timestamp collisions during testing.
* https://github.com/TBD54566975/dwn-sdk-js/issues/481
*/
public static async minimalSleep(): Promise<void> {
await Time.sleep(2);
}
/**
* Returns an UTC ISO-8601 timestamp with microsecond precision accepted by DWN.
* using @js-temporal/polyfill
*/
public static getCurrentTimestamp(): string {
return Temporal.Now.instant().toString({ smallestUnit: 'microseconds' });
}
/**
* Creates a UTC ISO-8601 timestamp in microsecond precision accepted by DWN.
* @param options - Options for creating the timestamp.
* @returns string
*/
public static createTimestamp(options: {
year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number, millisecond?: number, microsecond?: number
}): string {
const { year, month, day, hour, minute, second, millisecond, microsecond } = options;
return Temporal.ZonedDateTime.from({
timeZone: 'UTC',
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond
}).toInstant().toString({ smallestUnit: 'microseconds' });
}
/**
* Creates a UTC ISO-8601 timestamp offset from now or given timestamp accepted by DWN.
* @param offset Negative number means offset into the past.
*/
public static createOffsetTimestamp(offset: { seconds: number }, timestamp?: string): string {
const timestampInstant = timestamp ? Temporal.Instant.from(timestamp) : Temporal.Now.instant();
const offsetDuration = Temporal.Duration.from(offset);
const offsetInstant = timestampInstant.add(offsetDuration);
return offsetInstant.toString({ smallestUnit: 'microseconds' });
}
/**
* Validates that the provided timestamp is a valid number
* @param timestamp the timestamp to validate
* @throws DwnError if timestamp is not a valid number
*/
public static validateTimestamp(timestamp: string): void {
try {
Temporal.Instant.from(timestamp);
} catch {
throw new DwnError(DwnErrorCode.TimestampInvalid, `Invalid timestamp: ${timestamp}`);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
export function validateProtocolUrlNormalized(url: string): void {
let normalized: string | undefined;
try {
normalized = normalizeProtocolUrl(url);
} catch {
normalized = undefined;
}
if (url !== normalized) {
throw new DwnError(DwnErrorCode.UrlProtocolNotNormalized, `Protocol URI ${url} must be normalized.`);
}
}
export function normalizeProtocolUrl(url: string): string {
// Keeping protocol normalization as a separate function in case
// protocol and schema normalization diverge in the future
return normalizeUrl(url);
}
export function validateSchemaUrlNormalized(url: string): void {
let normalized: string | undefined;
try {
normalized = normalizeSchemaUrl(url);
} catch {
normalized = undefined;
}
if (url !== normalized) {
throw new DwnError(DwnErrorCode.UrlSchemaNotNormalized, `Schema URI ${url} must be normalized.`);
}
}
export function normalizeSchemaUrl(url: string): string {
// Keeping schema normalization as a separate function in case
// protocol and schema normalization diverge in the future
return normalizeUrl(url);
}
function normalizeUrl(url: string): string {
let fullUrl: string;
if (/^[^:]+:(\/{2})?[^\/].*/.test(url)) {
fullUrl = url;
} else {
fullUrl = `http://${url}`;
}
try {
const result = new URL(fullUrl);
result.search = '';
result.hash = '';
return removeTrailingSlash(result.href);
} catch (e) {
throw new DwnError(DwnErrorCode.UrlProtocolNotNormalizable, 'Could not normalize protocol URI');
}
}
function removeTrailingSlash(str: string): string {
if (str.endsWith('/')) {
return str.slice(0, -1);
} else {
return str;
}
}