diff --git a/package.json b/package.json index 7e41cae..b615121 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,10 @@ "./validator": { "types": "./dist/src/validator.d.ts", "import": "./dist/src/validator.js" + }, + "./pb": { + "types": "./dist/src/pb/ipns.d.ts", + "import": "./dist/src/pb/ipns.js" } }, "eslintConfig": { diff --git a/src/validator.ts b/src/validator.ts index 9427120..f217da9 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -2,9 +2,23 @@ import { publicKeyFromMultihash } from '@libp2p/crypto/keys' import { logger } from '@libp2p/logger' import NanoDate from 'timestamp-nano' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' -import { InvalidEmbeddedPublicKeyError, RecordExpiredError, RecordTooLargeError, SignatureVerificationError, UnsupportedValidityError } from './errors.js' +import { + InvalidEmbeddedPublicKeyError, + RecordExpiredError, + RecordTooLargeError, + SignatureVerificationError, + UnsupportedValidityError +} from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { extractPublicKeyFromIPNSRecord, ipnsRecordDataForV2Sig, isCodec, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from './utils.js' +import { + extractPublicKeyFromIPNSRecord, + ipnsRecordDataForV2Sig, + isCodec, + multihashFromIPNSRoutingKey, + multihashToIPNSRoutingKey, + unmarshalIPNSRecord +} from './utils.js' +import type { IPNSRecord } from './index.js' import type { PublicKey } from '@libp2p/interface' const log = logger('ipns:validator') @@ -18,7 +32,7 @@ const MAX_RECORD_SIZE = 1024 * 10 * Validates the given IPNS Record against the given public key. We need a "raw" * record in order to be able to access to all of its fields. */ -export const validate = async (publicKey: PublicKey, marshalledRecord: Uint8Array): Promise => { +export async function validate (publicKey: PublicKey, marshalledRecord: Uint8Array): Promise { // unmarshal ensures that (1) SignatureV2 and Data are present, (2) that ValidityType // and Validity are of valid types and have a value, (3) that CBOR data matches protobuf // if it's a V1+V2 record. @@ -92,3 +106,29 @@ export async function ipnsValidator (routingKey: Uint8Array, marshalledRecord: U // Record validation await validate(recordPubKey, marshalledRecord) } + +/** + * Returns the number of milliseconds until the record expires. + * If the record is already expired, throws an error. + * + * @param record - The IPNS record to validate. + * @returns The number of milliseconds until the record expires. + */ +export function validFor (record: IPNSRecord): number { + if (record.validityType !== IpnsEntry.ValidityType.EOL) { + throw new UnsupportedValidityError() + } + + if (record.validity == null) { + throw new UnsupportedValidityError() + } + + const validUntil = NanoDate.fromString(record.validity).toDate().getTime() + const now = Date.now() + + if (validUntil < now) { + throw new RecordExpiredError('The record has expired') + } + + return validUntil - now +} diff --git a/test/validator.spec.ts b/test/validator.spec.ts index 673d443..68a6c0b 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -4,9 +4,9 @@ import { randomBytes } from '@libp2p/crypto' import { generateKeyPair, publicKeyToProtobuf } from '@libp2p/crypto/keys' import { expect } from 'aegir/chai' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' -import { InvalidEmbeddedPublicKeyError, RecordTooLargeError, SignatureVerificationError } from '../src/errors.js' +import { InvalidEmbeddedPublicKeyError, RecordTooLargeError, SignatureVerificationError, RecordExpiredError, UnsupportedValidityError } from '../src/errors.js' import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey } from '../src/index.js' -import { ipnsValidator } from '../src/validator.js' +import { ipnsValidator, validFor } from '../src/validator.js' import type { PrivateKey } from '@libp2p/interface' describe('validator', function () { @@ -91,4 +91,40 @@ describe('validator', function () { await expect(ipnsValidator(key, marshalledData)).to.eventually.be.rejected() .with.property('name', RecordTooLargeError.name) }) + + describe('validFor', () => { + it('should return the number of milliseconds until the record expires', async () => { + const record = await createIPNSRecord(privateKey1, contentPath, 0, 1000000) + const result = validFor(record) + expect(result).to.be.greaterThan(0) + }) + + it('should throw RecordExpiredError for expired records', async () => { + const record = await createIPNSRecord(privateKey1, contentPath, 0, 0) + + expect(() => validFor(record)).to.throw(RecordExpiredError) + }) + + it('should throw UnsupportedValidityError for non-EOL validity types', async () => { + const record = await createIPNSRecord(privateKey1, contentPath, 0, 1000000) + record.validityType = 5 as any + + expect(() => validFor(record)).to.throw(UnsupportedValidityError) + }) + + it('should throw UnsupportedValidityError for null validity', async () => { + const record = await createIPNSRecord(privateKey1, contentPath, 0, 1000000) + record.validityType = null as any + + expect(() => validFor(record)).to.throw(UnsupportedValidityError) + }) + + it('should return correct milliseconds until expiration', async () => { + const record = await createIPNSRecord(privateKey1, contentPath, 0, 5000) + + const result = validFor(record) + + expect(result).to.be.within(4900, 5000) + }) + }) })