-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* initial implementation * add statement generation * move misc stuff to ./utils * fix some errors * setup jest and ts config * default restriction to {}, meaning "anything" * use canonicalize for encoding, improve decoding checks * allow * namespace * improve constructor * fix restriction type and insertion * add fns for extracting from and inserting into a SiweMessage * basic tests * test files * impl merging of recaps
- Loading branch information
1 parent
7a35ad9
commit cf5116b
Showing
13 changed files
with
3,847 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
dist | ||
node_modules |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"root": true, | ||
"parser": "@typescript-eslint/parser", | ||
"plugins": ["@typescript-eslint"], | ||
"extends": [ | ||
"eslint:recommended", | ||
"plugin:@typescript-eslint/eslint-recommended", | ||
"plugin:@typescript-eslint/recommended", | ||
"prettier" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
const { pathsToModuleNameMapper } = require('ts-jest') | ||
// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file | ||
// which contains the path mapping (ie the `compilerOptions.paths` option): | ||
const { compilerOptions } = require('./tsconfig') | ||
|
||
/** @type {import('ts-jest').JestConfigWithTsJest} */ | ||
module.exports = { | ||
preset: 'ts-jest', | ||
testEnvironment: 'node', | ||
roots: ['<rootDir>/src'], | ||
modulePathIgnorePatterns: ['<rootDir>/dist/'], | ||
modulePaths: [compilerOptions.baseUrl], | ||
moduleNameMapper: Object.assign( | ||
pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }), | ||
{ | ||
'^multiformats(.*)$': '<rootDir>/node_modules/multiformats/dist/index.min.js', | ||
'^ethers(.*)$': '<rootDir>/node_modules/ethers/lib/index.js', | ||
} | ||
) | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
{ | ||
"name": "recap-ts", | ||
"version": "0.0.1", | ||
"description": "A Typescript implementation of EIP-5573 utilities", | ||
"main": "dist/index.js", | ||
"types": "dist/siwe.d.ts", | ||
"license": "MIT", | ||
"author": "Spruce Systems Inc.", | ||
"scripts": { | ||
"build": "tsc", | ||
"test": "jest", | ||
"lint": "eslint --ext .js,.ts .", | ||
"format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json)\"" | ||
}, | ||
"devDependencies": { | ||
"@types/jest": "^29.5.0", | ||
"@typescript-eslint/eslint-plugin": "^5.23.0", | ||
"@typescript-eslint/parser": "^5.23.0", | ||
"eslint": "^8.38.0", | ||
"eslint-config-prettier": "^8.5.0", | ||
"jest": "^29.5.0", | ||
"prettier": "^2.8.7", | ||
"ts-jest": "^29.1.0", | ||
"typescript": "^5.0.2", | ||
"ethers": "^5.5.1" | ||
}, | ||
"dependencies": { | ||
"multiformats": "^11.0.2", | ||
"canonicalize": "^2.0.0", | ||
"siwe": "^2.0.5" | ||
}, | ||
"peerDependencies": { | ||
"ethers": "^5.5.1" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"semi": true, | ||
"singleQuote": true, | ||
"arrowParens": "avoid", | ||
"bracketSameLine": true, | ||
"printWidth": 80, | ||
"tabWidth": 2, | ||
"useTabs": false, | ||
"endOfLine": "lf" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
import { CID } from 'multiformats/cid'; | ||
import { SiweMessage } from 'siwe'; | ||
|
||
import { Recap } from './index'; | ||
import { validAbString, isSorted, validString } from './utils'; | ||
|
||
const jsonCap = require('../test/serialized_cap.json'); | ||
const valid = require('../test/valid.json'); | ||
const invalid = require('../test/invalid.json'); | ||
|
||
describe('Recap Handling', () => { | ||
test('should build a recap', () => { | ||
const recap = new Recap(); | ||
|
||
expect(recap.proofs).toEqual([]); | ||
|
||
recap.addAttenuation('https://example.com', 'crud', 'read'); | ||
expect(recap.attenuations).toEqual({ | ||
'https://example.com': { 'crud/read': [{}] }, | ||
}); | ||
expect(recap.proofs).toEqual([]); | ||
|
||
recap.addAttenuation('kepler:example://default/kv', 'kv', 'read'); | ||
expect(recap.attenuations).toEqual({ | ||
'https://example.com': { 'crud/read': [{}] }, | ||
'kepler:example://default/kv': { 'kv/read': [{}] } | ||
}); | ||
expect(recap.proofs).toEqual([]); | ||
|
||
recap.addAttenuation('kepler:example://default/kv', 'kv', 'write', { max: 10 }); | ||
expect(recap.attenuations).toEqual({ | ||
'https://example.com': { 'crud/read': [{}] }, | ||
'kepler:example://default/kv': { 'kv/read': [{}], 'kv/write': [{ max: 10 }] } | ||
}); | ||
expect(recap.proofs).toEqual([]); | ||
|
||
const cidStr = 'bagaaierasords4njcts6vs7qvdjfcvgnume4hqohf65zsfguprqphs3icwea'; | ||
const cid = CID.parse(cidStr); | ||
|
||
recap.addProof(cidStr); | ||
expect(recap.proofs).toEqual([cid]); | ||
}); | ||
test('should decode properly', () => { | ||
// @ts-ignore | ||
for (const { message, recap } of Object.values(valid).map( | ||
// @ts-ignore | ||
({ message, recap: { att, prf } }) => ({ | ||
// @ts-ignore | ||
message: new SiweMessage(message), | ||
// @ts-ignore | ||
recap: { att, prf: prf.map(CID.decode) } | ||
})) | ||
) { | ||
let decoded; | ||
// @ts-ignore | ||
expect(() => decoded = Recap.extract_and_verify(message)).not.toThrow(); | ||
// @ts-ignore | ||
expect(decoded.attenuations).toEqual(recap.att); | ||
// @ts-ignore | ||
let proofs = recap.prf.map(CID.decode); | ||
// @ts-ignore | ||
expect(decoded.proofs).toEqual(proofs); | ||
} | ||
// @ts-ignore | ||
for (const { message } of Object.values(invalid)) { | ||
expect(() => Recap.extract_and_verify(message)).toThrow(); | ||
} | ||
}) | ||
}) | ||
|
||
describe('Utils', () => { | ||
const unordered = { | ||
c: 1, | ||
b: 2, | ||
ca: 3, | ||
bnested: { | ||
c: [3, 2, 1], | ||
b: 2 | ||
} | ||
}; | ||
const ordered = { | ||
b: 2, | ||
bnested: { | ||
b: 2, | ||
c: [3, 2, 1] | ||
}, | ||
c: 1, | ||
ca: 3 | ||
}; | ||
const validStrings = ['crud', 'kepler', 'https-proto']; | ||
const validAbilityStrings = ['crud/read', 'kepler/*', 'https/put']; | ||
const invalidAbilityStrings = ['crud', 'crud/read/write', 'with a/space', 'with/a space']; | ||
|
||
test('should test for ordering', () => { | ||
expect(isSorted(ordered)).toBeTruthy(); | ||
expect(isSorted(unordered)).toBeFalsy(); | ||
}) | ||
test('should test for valid strings', () => { | ||
validStrings.forEach(str => expect(validString(str)).toBeTruthy()); | ||
validAbilityStrings.forEach((str) => { | ||
expect(validAbString(str)).toBeTruthy(); | ||
}) | ||
invalidAbilityStrings.forEach((str) => { | ||
expect(validAbString(str)).toBeFalsy(); | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import { CID } from 'multiformats/cid'; | ||
import { | ||
AttObj, | ||
PlainJSON, | ||
encodeRecap, | ||
decodeRecap, | ||
validString, | ||
checkAtt | ||
} from './utils'; | ||
import { SiweMessage } from 'siwe'; | ||
|
||
export { AttObj, PlainJSON, CID }; | ||
const urnRecapPrefix = 'urn:recap:'; | ||
|
||
export class Recap { | ||
#prf: Array<CID>; | ||
#att: AttObj; | ||
|
||
constructor(att: AttObj = {}, prf: Array<CID> | Array<string> = []) { | ||
checkAtt(att); | ||
this.#att = att; | ||
this.#prf = prf.map(cid => typeof cid === 'string' ? CID.parse(cid) : cid); | ||
} | ||
|
||
get proofs(): Array<CID> { | ||
return this.#prf; | ||
} | ||
|
||
get attenuations(): AttObj { | ||
return this.#att; | ||
} | ||
|
||
get statement(): string { | ||
let statement = 'I further authorize the stated URI to perform the following actions on my behalf: '; | ||
|
||
let section = 1; | ||
for (const resource of Object.keys(this.attenuations).sort()) { | ||
const resourceAbilities = Object.keys(this.attenuations[resource]).sort().reduce((acc, cur) => { | ||
const [namespace, name] = cur.split('/'); | ||
if (acc[namespace] === undefined) { | ||
acc[namespace] = [name]; | ||
} else { | ||
acc[namespace].push(name); | ||
} | ||
return acc; | ||
}, {} as { [key: string]: Array<string> }); | ||
|
||
for (const [namespace, names] of Object.entries(resourceAbilities)) { | ||
statement += `(${section}) "${namespace}": ${names.map(n => '"' + n + '"').join(', ')} for "${resource}". `; | ||
section += 1; | ||
} | ||
} | ||
|
||
return statement | ||
} | ||
|
||
addProof(cid: string | CID) { | ||
if (typeof cid === 'string') { | ||
this.#prf.push(CID.parse(cid)) | ||
} else { | ||
this.#prf.push(cid); | ||
} | ||
} | ||
|
||
addAttenuation(resource: string, namespace: string = '*', name: string = '*', restriction: { [key: string]: PlainJSON } = {}) { | ||
if (!validString(namespace)) { | ||
throw new Error('Invalid ability namespace'); | ||
} | ||
if (!validString(name)) { | ||
throw new Error('Invalid ability name'); | ||
} | ||
|
||
const abString = `${namespace}/${name}`; | ||
const ex = this.#att[resource]; | ||
|
||
if (ex !== undefined) { | ||
if (ex[abString] !== undefined) { | ||
ex[abString].push(restriction); | ||
} else { | ||
ex[abString] = [restriction]; | ||
} | ||
} else { | ||
this.#att[resource] = { [abString]: [restriction] }; | ||
} | ||
} | ||
|
||
merge(other: Recap) { | ||
this.#prf.push(...other.proofs.filter(cid => !this.#prf.includes(cid))); | ||
|
||
for (const [resource, abilities] of Object.entries(other.attenuations)) { | ||
if (this.#att[resource] !== undefined) { | ||
const ex = this.#att[resource]; | ||
for (const [ability, restrictions] of Object.entries(abilities)) { | ||
if (ex[ability] === undefined || ex[ability].length === 0 | ||
|| ex[ability].every(r => Object.keys(r).length === 0)) { | ||
ex[ability] = restrictions; | ||
} else { | ||
ex[ability].push(...restrictions); | ||
} | ||
} | ||
} else { | ||
this.#att[resource] = abilities; | ||
} | ||
} | ||
} | ||
|
||
static decode_urn(recap: string): Recap { | ||
if (!recap.startsWith(urnRecapPrefix)) { | ||
throw new Error('Invalid recap urn'); | ||
} | ||
|
||
const { att, prf } = decodeRecap(recap.slice(urnRecapPrefix.length)); | ||
return new Recap(att, prf) | ||
} | ||
|
||
static extract(siwe: SiweMessage): Recap { | ||
if (siwe.resources === undefined) { | ||
throw new Error('No resources in SiweMessage'); | ||
} | ||
let last_index = siwe.resources.length - 1; | ||
return Recap.decode_urn(siwe.resources[last_index]) | ||
} | ||
|
||
static extract_and_verify(siwe: SiweMessage): Recap { | ||
const recap = Recap.extract(siwe); | ||
if (siwe.statement === undefined || !siwe.statement.endsWith(recap.statement)) { | ||
throw new Error('Invalid statement'); | ||
} | ||
return recap | ||
} | ||
|
||
add_to_siwe_message(siwe: SiweMessage): SiweMessage { | ||
try { | ||
// try merge with existing recap | ||
if (siwe.statement === undefined || siwe.resources === undefined || siwe.resources.length === 0) { | ||
throw new Error('no recap') | ||
} | ||
let other = Recap.extract_and_verify(siwe); | ||
let previousStatement = other.statement; | ||
other.merge(this); | ||
siwe.statement = siwe.statement.slice(0, -previousStatement.length) + other.statement; | ||
siwe.resources[siwe.resources.length - 1] = other.encode(); | ||
return siwe | ||
} catch (e) { | ||
// no existing recap, just apply it | ||
siwe.statement = siwe.statement === undefined ? this.statement : siwe.statement + ' ' + this.statement; | ||
siwe.resources = siwe.resources === undefined ? [this.encode()] : [...siwe.resources, this.encode()]; | ||
return siwe | ||
} | ||
} | ||
|
||
encode(): string { | ||
return `${urnRecapPrefix}${encodeRecap(this.#att, this.#prf)}` | ||
} | ||
} |
Oops, something went wrong.