@dfinity/identity
Version:
JavaScript and TypeScript library to manage identity with the Internet Computer
412 lines (377 loc) • 13.2 kB
text/typescript
import {
type DerEncodedPublicKey,
type HttpAgentRequest,
type PublicKey,
requestIdOf,
type Signature,
SignIdentity,
IC_REQUEST_DOMAIN_SEPARATOR,
IC_REQUEST_AUTH_DELEGATION_DOMAIN_SEPARATOR,
ToCborValue,
} from '@dfinity/agent';
import { Principal } from '@dfinity/principal';
import { PartialIdentity } from './partial.ts';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
/**
* Safe wrapper around bytesToHex that handles ArrayBuffer/Uint8Array type conversion.
* Required because @noble/hashes v1.8+ strictly expects Uint8Array inputs.
* @param data The binary data to convert to hexadecimal string (ArrayBuffer, Uint8Array, or ArrayLike<number>)
*/
function safeBytesToHex(data: ArrayBuffer | Uint8Array | ArrayLike<number>): string {
if (data instanceof Uint8Array) {
return bytesToHex(data);
}
return bytesToHex(new Uint8Array(data));
}
function _parseBlob(value: unknown): Uint8Array {
if (typeof value !== 'string' || value.length < 64) {
throw new Error('Invalid public key.');
}
return hexToBytes(value);
}
/**
* A single delegation object that is signed by a private key. This is constructed by
* `DelegationChain.create()`.
*
* {@see DelegationChain}
*/
export class Delegation implements ToCborValue {
constructor(
public readonly pubkey: Uint8Array,
public readonly expiration: bigint,
public readonly targets?: Principal[],
) {}
public toCborValue() {
return {
pubkey: this.pubkey,
expiration: this.expiration,
...(this.targets && {
targets: this.targets,
}),
};
}
public toJSON(): JsonnableDelegation {
// every string should be hex and once-de-hexed,
// discoverable what it is (e.g. de-hex to get JSON with a 'type' property, or de-hex to DER
// with an OID). After de-hex, if it's not obvious what it is, it's an ArrayBuffer.
return {
expiration: this.expiration.toString(16),
pubkey: safeBytesToHex(this.pubkey),
...(this.targets && { targets: this.targets.map(p => p.toHex()) }),
};
}
}
/**
* Type of ReturnType<Delegation.toJSON>.
* The goal here is to stringify all non-JSON-compatible types to some bytes representation we can
* stringify as hex.
* (Hex shouldn't be ambiguous ever, because you can encode as DER with semantic OIDs).
*/
interface JsonnableDelegation {
// A BigInt of Nanoseconds since epoch as hex
expiration: string;
// Hexadecimal representation of the DER public key.
pubkey: string;
// Array of strings, where each string is hex of principal blob (*NOT* textual representation).
targets?: string[];
}
/**
* A signed delegation, which lends its identity to the public key in the delegation
* object. This is constructed by `DelegationChain.create()`.
*
* {@see DelegationChain}
*/
export interface SignedDelegation {
delegation: Delegation;
signature: Signature;
}
/**
* Sign a single delegation object for a period of time.
* @param from The identity that lends its delegation.
* @param to The identity that receives the delegation.
* @param expiration An expiration date for this delegation.
* @param targets Limit this delegation to the target principals.
*/
async function _createSingleDelegation(
from: SignIdentity,
to: PublicKey,
expiration: Date,
targets?: Principal[],
): Promise<SignedDelegation> {
const delegation: Delegation = new Delegation(
to.toDer(),
BigInt(+expiration) * BigInt(1000000), // In nanoseconds.
targets,
);
// The signature is calculated by signing the concatenation of the domain separator
// and the message.
// Note: To ensure Safari treats this as a user gesture, ensure to not use async methods
// besides the actualy webauthn functionality (such as `sign`). Safari will de-register
// a user gesture if you await an async call thats not fetch, xhr, or setTimeout.
const challenge = new Uint8Array([
...IC_REQUEST_AUTH_DELEGATION_DOMAIN_SEPARATOR,
...new Uint8Array(requestIdOf({ ...delegation })),
]);
const signature = await from.sign(challenge);
return {
delegation,
signature,
};
}
export interface JsonnableDelegationChain {
publicKey: string;
delegations: Array<{
signature: string;
delegation: {
pubkey: string;
expiration: string;
targets?: string[];
};
}>;
}
/**
* A chain of delegations. This is JSON Serializable.
* This is the object to serialize and pass to a DelegationIdentity. It does not keep any
* private keys.
*/
export class DelegationChain {
/**
* Create a delegation chain between two (or more) keys. By default, the expiration time
* will be very short (15 minutes).
*
* To build a chain of more than 2 identities, this function needs to be called multiple times,
* passing the previous delegation chain into the options argument. For example:
* @example
* const rootKey = createKey();
* const middleKey = createKey();
* const bottomeKey = createKey();
*
* const rootToMiddle = await DelegationChain.create(
* root, middle.getPublicKey(), Date.parse('2100-01-01'),
* );
* const middleToBottom = await DelegationChain.create(
* middle, bottom.getPublicKey(), Date.parse('2100-01-01'), { previous: rootToMiddle },
* );
*
* // We can now use a delegation identity that uses the delegation above:
* const identity = DelegationIdentity.fromDelegation(bottomKey, middleToBottom);
* @param from The identity that will delegate.
* @param to The identity that gets delegated. It can now sign messages as if it was the
* identity above.
* @param expiration The length the delegation is valid. By default, 15 minutes from calling
* this function.
* @param options A set of options for this delegation. expiration and previous
* @param options.previous - Another DelegationChain that this chain should start with.
* @param options.targets - targets that scope the delegation (e.g. Canister Principals)
*/
public static async create(
from: SignIdentity,
to: PublicKey,
expiration: Date = new Date(Date.now() + 15 * 60 * 1000),
options: {
previous?: DelegationChain;
targets?: Principal[];
} = {},
): Promise<DelegationChain> {
const delegation = await _createSingleDelegation(from, to, expiration, options.targets);
return new DelegationChain(
[...(options.previous?.delegations || []), delegation],
options.previous?.publicKey || from.getPublicKey().toDer(),
);
}
/**
* Creates a DelegationChain object from a JSON string.
* @param json The JSON string to parse.
*/
public static fromJSON(json: string | JsonnableDelegationChain): DelegationChain {
const { publicKey, delegations } = typeof json === 'string' ? JSON.parse(json) : json;
if (!Array.isArray(delegations)) {
throw new Error('Invalid delegations.');
}
const parsedDelegations: SignedDelegation[] = delegations.map(signedDelegation => {
const { delegation, signature } = signedDelegation;
const { pubkey, expiration, targets } = delegation;
if (targets !== undefined && !Array.isArray(targets)) {
throw new Error('Invalid targets.');
}
return {
delegation: new Delegation(
_parseBlob(pubkey),
BigInt('0x' + expiration), // expiration in JSON is an hexa string (See toJSON() below).
targets &&
targets.map((t: unknown) => {
if (typeof t !== 'string') {
throw new Error('Invalid target.');
}
return Principal.fromHex(t);
}),
),
signature: _parseBlob(signature) as Signature,
};
});
return new this(parsedDelegations, _parseBlob(publicKey) as DerEncodedPublicKey);
}
/**
* Creates a DelegationChain object from a list of delegations and a DER-encoded public key.
* @param delegations The list of delegations.
* @param publicKey The DER-encoded public key of the key-pair signing the first delegation.
*/
public static fromDelegations(
delegations: SignedDelegation[],
publicKey: DerEncodedPublicKey,
): DelegationChain {
return new this(delegations, publicKey);
}
protected constructor(
public readonly delegations: SignedDelegation[],
public readonly publicKey: DerEncodedPublicKey,
) {}
public toJSON(): JsonnableDelegationChain {
return {
delegations: this.delegations.map(signedDelegation => {
const { delegation, signature } = signedDelegation;
const { targets } = delegation;
return {
delegation: {
expiration: delegation.expiration.toString(16),
pubkey: safeBytesToHex(delegation.pubkey),
...(targets && {
targets: targets.map(t => t.toHex()),
}),
},
signature: safeBytesToHex(signature),
};
}),
publicKey: safeBytesToHex(this.publicKey),
};
}
}
/**
* An Identity that adds delegation to a request. Everywhere in this class, the name
* innerKey refers to the SignIdentity that is being used to sign the requests, while
* originalKey is the identity that is being borrowed. More identities can be used
* in the middle to delegate.
*/
export class DelegationIdentity extends SignIdentity {
/**
* Create a delegation without having access to delegateKey.
* @param key The key used to sign the requests.
* @param delegation A delegation object created using `createDelegation`.
*/
public static fromDelegation(
key: Pick<SignIdentity, 'sign'>,
delegation: DelegationChain,
): DelegationIdentity {
return new this(key, delegation);
}
protected constructor(
private _inner: Pick<SignIdentity, 'sign'>,
private _delegation: DelegationChain,
) {
super();
}
public getDelegation(): DelegationChain {
return this._delegation;
}
public getPublicKey(): PublicKey {
return {
derKey: this._delegation.publicKey,
toDer: () => this._delegation.publicKey,
};
}
public sign(blob: Uint8Array): Promise<Signature> {
return this._inner.sign(blob);
}
public async transformRequest(request: HttpAgentRequest): Promise<unknown> {
const { body, ...fields } = request;
const requestId = await requestIdOf(body);
return {
...fields,
body: {
content: body,
sender_sig: await this.sign(
new Uint8Array([...IC_REQUEST_DOMAIN_SEPARATOR, ...new Uint8Array(requestId)]),
),
sender_delegation: this._delegation.delegations,
sender_pubkey: this._delegation.publicKey,
},
};
}
}
/**
* A partial delegated identity, representing a delegation chain and the public key that it targets
*/
export class PartialDelegationIdentity extends PartialIdentity {
#delegation: DelegationChain;
/**
* The Delegation Chain of this identity.
*/
get delegation(): DelegationChain {
return this.#delegation;
}
private constructor(inner: PublicKey, delegation: DelegationChain) {
super(inner);
this.#delegation = delegation;
}
/**
* Create a {@link PartialDelegationIdentity} from a {@link PublicKey} and a {@link DelegationChain}.
* @param key The {@link PublicKey} to delegate to.
* @param delegation a {@link DelegationChain} targeting the inner key.
*/
public static fromDelegation(key: PublicKey, delegation: DelegationChain) {
return new PartialDelegationIdentity(key, delegation);
}
}
/**
* List of things to check for a delegation chain validity.
*/
export interface DelegationValidChecks {
/**
* Check that the scope is amongst the scopes that this delegation has access to.
*/
scope?: Principal | string | Array<Principal | string>;
}
/**
* Analyze a DelegationChain and validate that it's valid, ie. not expired and apply to the
* scope.
* @param chain The chain to validate.
* @param checks Various checks to validate on the chain.
*/
export function isDelegationValid(chain: DelegationChain, checks?: DelegationValidChecks): boolean {
// Verify that the no delegation is expired. If any are in the chain, returns false.
for (const { delegation } of chain.delegations) {
// prettier-ignore
if (+new Date(Number(delegation.expiration / BigInt(1000000))) <= +Date.now()) {
return false;
}
}
// Check the scopes.
const scopes: Principal[] = [];
const maybeScope = checks?.scope;
if (maybeScope) {
if (Array.isArray(maybeScope)) {
scopes.push(...maybeScope.map(s => (typeof s === 'string' ? Principal.fromText(s) : s)));
} else {
scopes.push(typeof maybeScope === 'string' ? Principal.fromText(maybeScope) : maybeScope);
}
}
for (const s of scopes) {
const scope = s.toText();
for (const { delegation } of chain.delegations) {
if (delegation.targets === undefined) {
continue;
}
let none = true;
for (const target of delegation.targets) {
if (target.toText() === scope) {
none = false;
break;
}
}
if (none) {
return false;
}
}
}
return true;
}